MainSDK
Search⌘K
How-to

API service reference

A Node API with signed webhooks, jobs with a dead-letter queue and an outbound partner call, on the SOC 2 baseline.

Planned for 0.7

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

Start from it: npx mainsdk init --template mainsdk/api-service

The reference architecture for an HTTP API service: a verified inbound webhook, background jobs that retry and then land in a dead-letter queue, and sign-in for the routes people call. Pack: SOC 2 baseline.

The handler is a plain fetch function, so Hono or Express can sit in front of it. The interfaces (webhooks, jobs, auth, http) stay the same.

Runs with no account and no provider key: npm run dev serves it on local drivers. npm test runs the tests: a delivery with a bad signature is refused before the handler, a job that fails three times lands in the dead-letter queue.

Every change and every MainSDK release runs .github/workflows/reference.yml: mainsdk check, mainsdk drill restore --to scratch and mainsdk assure --standard soc2, npm run bench and npm run chaos (the partner down, slow or answering nonsense must never lose a delivery). npm run verify-ci fails when a job is removed.

Moving off Express with raw provider SDKs? mainsdk migrate raw ends here.

Performance

The benchmark harness fails a change when p95 goes over 25 ms on local drivers (npm run bench).

main.yaml

yaml
schema: 1
project: prj_template_api_service
baseline: main27
auth:
  driver: local
  methods: [passkeys, link]
  admin: { require: phishing-resistant }
db:
  driver: local
jobs:
  driver: local
  dead-letter: true
webhooks:
  inbound:
    github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /webhooks/github }
logging:
  audit: true
  retention: 12m
network:
  allow: [status.partner.dev]
  default: deny
  mode: observe
  http:
    timeout: 2s
    retries: { attempts: 1, backoff: 100ms, max: 1s }
# Evidence for `mainsdk assure --standard soc2`: change control is on (a pull request and a green gate before every merge),
# and the providers the app runs on are registered.
compliance:
  change-control: true
  providers: [local, ./backup.ts]
backup:
  rpo: 15m
  rto: 1h
  keep: 7d
  drill: { every: 30d, to: scratch }
  driver: ./backup.ts

app.ts

ts
// The API service reference architecture: an HTTP service with a verified inbound webhook, a background job that
// retries and then lands in the dead-letter queue, and sign-in for the routes people call. Hono or Express can
// replace the fetch handler; the interfaces (webhooks, jobs, auth) stay the same.
import { auth, http, jobs, MainError, webhooks } from 'mainsdk'

const synced: string[] = []

/**
 * The job the webhook queues: ask the partner whether it is ready, then record the sync. A partner that is down, slow
 * or answering nonsense fails the attempt; the queue retries it and then keeps it in the dead-letter queue, so no
 * delivery is lost. A repository named `broken` always fails, to show the retries.
 */
async function syncRepo(input: { repo: string }): Promise<void> {
  if (input.repo === 'broken')
    throw new MainError('MAIN-APP-0001', { detail: `Could not sync ${input.repo}`, status: 502 })
  const res = await http.fetch('https://status.partner.dev/v1/ready')
  const body = (await res.json()) as { ready?: boolean }
  if (!res.ok || body.ready !== true)
    throw new MainError('MAIN-APP-0002', { detail: 'The partner is not ready', status: 503 })
  synced.push(input.repo)
}

/** The inbound webhook: the signature is checked over the raw body before this runs; a bad one never gets here. */
async function onDelivery(body: unknown, meta: { id: string }): Promise<void> {
  const repo = String((body as { repository?: { name?: string } }).repository?.name ?? '')
  // One job per delivery: the provider's delivery id is the idempotency key, so a duplicate queues nothing.
  await jobs.run('repo.sync', { repo }, { idempotencyKey: meta.id, retries: 2, backoff: 'fixed' })
}

/** Register the handlers on the running app. It is cheap and safe to repeat, so every request does it. */
function wire(): ReturnType<typeof webhooks.receive> {
  jobs.on('repo.sync', syncRepo)
  return webhooks.receive('github', onDelivery)
}

const json = (status: number, body: unknown): Response => Response.json(body, { status })

/** The service: its routes, with every interface refusal answered as a problem and never a crash. */
export default async function fetch(req: Request): Promise<Response> {
  try {
    const receive = wire()
    const { pathname } = new URL(req.url)
    if (req.method === 'GET' && pathname === '/health') return json(200, { ok: true })
    if (req.method === 'POST' && pathname === '/webhooks/github') return await receive(req)
    await auth.require(req)
    if (req.method === 'GET' && pathname === '/repos') return json(200, { synced })
    if (req.method === 'GET' && pathname === '/jobs/dead') return json(200, await jobs.dead.list())
    return json(404, { error: 'not found' })
  } catch (e) {
    if (e instanceof MainError) return json(e.status, { code: e.code, title: e.title, fix: e.fix })
    throw e
  }
}

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet