MainSDK
Search⌘K
Interfaces

Events, jobs and webhooks

Webhooks

webhooks

Send webhooks signed per Standard Webhooks with retries and a delivery log, and verify incoming ones from Stripe, GitHub and more before your handler runs.

Previewships as a stable APIPhase 1 · Core, public betaSpec webhooks-jobsFree in your app, Apache-2.0

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

webhooks sends signed webhooks with retries and a delivery log, and verifies incoming ones before your handler runs. Use it when your app notifies other systems or Stripe or GitHub calls you.

At a glance

webhooks:
  driver: builtin
  secret: secret:WEBHOOK_SIGNING_KEY
  retries: { max: 8, window: 24h }

Calls

  • webhooks.emit()
  • webhooks.receive()
  • webhooks.delivery(id)
  • new Webhooks(inbound, outbound)
  • PRESETS(const)
  • devPaths(dir)
  • newSecret()
  • readDeliveries(dir, now)
  • replayKey(dir, create)
  • replaySignature(key, deliveryId, { body })
  • signHeaders(secrets, id, { timestampSeconds, body })
  • webhooks.handle(req, { remote? })
  • webhooks.send(type, data, { idempotencyKey? })

Guarantees

Whatever the driver.

  • Outbound webhooks signed per Standard Webhooks
  • Inbound signatures and timestamps checked before the handler
  • Replays older than 5 minutes refused
  • Duplicates call the handler once

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (default)
webhooks:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-HOOK-0011Checkmainsdk check, before deployA twilio inbound entry has no public-url
MAIN-HOOK-0002RuntimeError responses and logsIncoming webhook with a wrong signature or a timestamp too old (400)
MAIN-HOOK-0001RuntimeError responses and logsSee the spec
MAIN-HOOK-0003RuntimeError responses and logswebhooks.receive names neither a key under webhooks.inbound nor a preset, or an entry names an unknown preset
MAIN-HOOK-0004RuntimeError responses and logsA customer endpoint URL is refused: not https, carries credentials, or is or resolves to a loopback, private, link-local, shared, multicast or metadata address (400; a delivery fails without connecting)
MAIN-HOOK-0005RuntimeError responses and logsThe tenant already has webhooks.endpoints.max-per-tenant endpoints (409)
MAIN-HOOK-0006RuntimeError responses and logsAn event type that isn't under webhooks.endpoints.events (400)
MAIN-HOOK-0007RuntimeError responses and logsAn endpoint was disabled after disable-after failures; an audit event and webhook.endpoint.disabled are written
MAIN-HOOK-0008RuntimeError responses and logsA test or redeliver to a disabled endpoint (409)
MAIN-HOOK-0009RuntimeError responses and logsNo endpoint or delivery with that id in the current tenant (404)
MAIN-HOOK-0010RuntimeError responses and logsAn endpoints call without the webhooks.manage permission (403)
MAIN-HOOK-0012RuntimeError responses and logsmainsdk webhooks replay refused: not a dev environment, no mainsdk dev running, or an unknown id (exit 2)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
webhooks@1 contractPhase 10.3webhooks-jobs#contract
Shared idempotency recordPhase 10.3webhooks-jobs#idempotency-store
Signed delivery with retries and a delivery logPhase 10.3webhooks-jobs#emit
Verified receivePhase 10.3webhooks-jobs#receive
Built-in and Svix driversPhase 10.3webhooks-jobs#drivers-webhooks

Quick start

Declare the inbound endpoint and its secret, then receive it in a route handler.

main.yaml
webhooks:
  inbound:
    github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /api/webhooks/github }
app/api/webhooks/github/route.ts
import { webhooks } from "mainsdk";

declare function recordPush(body: unknown, deliveryId: string): Promise<void>;

export const POST = webhooks.receive("github", async (body, meta) => {
  await recordPush(body, meta.id);   // your code; runs only after the signature is checked
});

A request with a wrong signature or a stale timestamp never reaches your handler. It gets 400 with MAIN-HOOK-0002 and is logged without its body. The handler gets the parsed body and meta: { id, name, preset, headers, raw, replay }, where id is the provider's delivery id.

Configuration

main.yaml
webhooks:
  secret: secret:WEBHOOK_SIGNING_KEY    # signs outbound deliveries from webhooks.emit
  retries: { max: 8, window: 24h }      # attempts before giving up, and the time they may span
  inbound:                              # every inbound endpoint the app serves
    stripe: { preset: stripe, secret: secret:STRIPE_WEBHOOK_SECRET, path: /api/webhooks/stripe }
    github: { preset: github, secret: [secret:GITHUB_HOOK_NEW, secret:GITHUB_HOOK_OLD], path: /api/webhooks/github }
    twilio: { preset: twilio, secret: secret:TWILIO_AUTH_TOKEN, path: /api/webhooks/twilio, public-url: https://app.pinecart.dev }
network:
  allow: [hooks.partner.dev]            # hosts webhooks.emit may call
KeyTypeWhat it does
webhooks.secretsecret referenceSigning key for webhooks.emit(); without it emit fails with MAIN-HOOK-0001
webhooks.retries.maxintegerMaximum delivery attempts (default 8)
webhooks.retries.windowdurationTime window for retries (default 24h)
webhooks.timeoutdurationTime one delivery attempt may take (default 10s). The runtime reads it; mainsdk check reports it as MAIN-CONFIG-0030 (low) until the schema lists it
webhooks.inbound.<name>.presetstringHow the sender signs: stripe, github, slack, shopify, twilio, razorpay, standard, mailgun, ses, postmark or getqr
webhooks.inbound.<name>.secretsecret reference or listThe signing secret. A list accepts a signature from any of them, so you rotate without downtime. A literal value fails with MAIN-CONFIG-0004
webhooks.inbound.<name>.pathstringThe path the adapter mounts and exempts from CSRF
webhooks.inbound.<name>.public-urlstringRequired for twilio, so the signed URL is rebuilt from it and never from the Host header
webhooks.inbound.<name>.topicslistFor ses, the SNS topics a subscription may confirm
network.allowlistThe hosts webhooks.emit() may send to

webhooks.receive("github", handler) with no inbound entry uses the preset's defaults and the secret secret:GITHUB_WEBHOOK_SECRET. A name that is neither a declared entry nor a preset fails with MAIN-HOOK-0003.

What you get

  • Outbound, webhooks.emit() signs each delivery per Standard Webhooks, retries with backoff and records every attempt in a delivery log.
  • Inbound, webhooks.receive() checks the signature and timestamp over the raw body before your handler runs.
  • Replays older than 5 minutes are refused, and your handler runs once even when the sender delivers twice.
  • Presets for Stripe, GitHub, Razorpay and any Standard Webhooks sender.
  • Customer-managed endpoints, and mainsdk webhooks replay to re-run a recorded delivery in development.

Inbound presets

Each preset verifies the signature over the exact raw body bytes with a constant-time compare, then passes the provider's delivery id to the idempotency record so a duplicate calls your handler once.

PresetReplay windowDelivery id for duplicates
stripe5 minutesevent id in the body
githubnoneX-GitHub-Delivery
slack5 minutesevent_id in the body
shopify5 minutes when X-Shopify-Triggered-At is sentX-Shopify-Webhook-Id
twiliononeI-Twilio-Idempotency-Token
razorpaynonex-razorpay-event-id
standard (Svix, Replicate, Resend, Clerk)5 minuteswebhook-id
mailgun5 minutestoken
ses1 hourMessageId
postmarknoneMessageID
getqr5 minutesevent id

A preset with no replay window relies on the duplicate check alone; mainsdk info marks it "no replay window". For Stripe and Razorpay payment events, prefer pay.on(), which reads pay.<provider>.webhook and gives you typed payment events.

Examples

Emit a signed webhook with retries

app/orders/ship.ts
import { webhooks } from "mainsdk";

export async function notifyPartner(order: { id: string }) {
  const deliveryId = await webhooks.emit(
    "https://hooks.partner.dev/in",
    { type: "order.shipped", data: { orderId: order.id } },
    { idempotencyKey: `order.shipped:${order.id}` },
  );
  return deliveryId;   // the delivery is queued; attempts run in the background
}

emit returns the delivery id once the delivery is queued. Each request carries webhook-id, webhook-timestamp and webhook-signature (v1,<base64>), which verify with any Standard Webhooks library. The body is a CloudEvents envelope. If the endpoint answers anything but 2xx, the same webhook-id is sent again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, up to retries.max attempts inside retries.window. Leave out idempotencyKey and one is made from the endpoint and the event. The URL goes through the network layer, so its host must be in network.allow. Read a delivery with webhooks.delivery(id); it lists each attempt with its status and response status, never a body.

Duplicates run your handler once

Senders retry. If the same event (same provider delivery id) arrives twice, your handler runs once and both requests get 200 with { "received": true }. You do not need your own dedupe table. If your handler throws, the sender gets a 500 and the delivery is not marked done, so its retry runs your handler again.

app/api/webhooks/github/route.ts
import { webhooks } from "mainsdk";

declare function recordDeploy(body: unknown): Promise<void>;

export const POST = webhooks.receive("github", async (body) => {
  await recordDeploy(body);   // safe: a redelivery of this event won't call the handler again
});

Keep handlers short. Hand slow work to jobs.run() so the sender gets its 200 quickly. A handler may return a Response (for example TwiML for Twilio); it is sent for the first delivery.

Replay a delivery in development

Under mainsdk dev, every inbound delivery is recorded in .mainsdk/ with its verification result, for 7 days or the last 500 deliveries. Nothing is recorded in any other environment.

bash
pnpm mainsdk webhooks list --json          # recorded deliveries, newest first
pnpm mainsdk webhooks replay <id>          # run your handler again with a new delivery id
pnpm mainsdk webhooks replay <id> --same-id   # keep the id, to test the duplicate check

A replay is signed with a local dev key that only a dev environment accepts from the loopback address. Outside dev, or with no dev server running, it exits 2 with MAIN-HOOK-0012.

Let your customers register endpoints

For endpoints your customers manage, the current tenant gets its own list: webhooks.endpoints.create({ url, events }) returns a whsec_ secret once, and webhooks.send(type, data) queues one signed delivery to every enabled endpoint subscribed to type. The other calls are endpoints.list, update, delete, rotate, test, deliveries and redeliver. Every webhooks.endpoints call needs the webhooks.manage permission, the URL must be public https and is checked again at every connect, and an endpoint that keeps failing is disabled after disable-after failures. List the event types tenants may subscribe to under webhooks.endpoints.events.

main.yaml
webhooks:
  endpoints:
    events: [order.created, order.shipped]
    max-per-tenant: 20
    disable-after: { failures: 50, for: 5d }
    keep-deliveries: 30d

These calls need tenancy and access rules set up; see the spec's customer-managed endpoints section for the full contract and the errors MAIN-HOOK-0004 to MAIN-HOOK-0010.

Errors and checks

Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link.

CodeWhenFix
MAIN-HOOK-0001webhooks.emit has no signing secret, or an event type or URL is invalidSet webhooks.secret: secret:WEBHOOK_SIGNING_KEY, use a type like order.shipped and a full https URL
MAIN-HOOK-0002An inbound request had a wrong signature, a timestamp outside the window, or an oversized body (400)Check the sender uses the secret you configured, and that its clock is correct
MAIN-HOOK-0003webhooks.receive names neither a webhooks.inbound entry nor a preset, or an entry names an unknown preset (check, runtime)Declare it under webhooks.inbound with one of the presets
MAIN-HOOK-0011A twilio inbound entry has no public-url (check)Set public-url to your app's public address
MAIN-HOOK-0012mainsdk webhooks replay refused: not a dev environment, no dev server, or an unknown id (CLI, exit 2)Run it under mainsdk dev with a recorded id

Customer endpoint errors are MAIN-HOOK-0004 (URL refused), 0005 (too many endpoints), 0006 (event type not allowed), 0007 (endpoint disabled after failures), 0008 (test or redeliver to a disabled endpoint), 0009 (not found in this tenant) and 0010 (needs webhooks.manage). An endpoint that keeps failing is retried up to retries.max within retries.window; every attempt is in the delivery log.

Swapping drivers

Outbound webhooks are stateless actions, so a driver swap needs no data migration. svix is the planned second driver; your webhooks.emit() and webhooks.receive() calls stay the same when it ships.

Testing

Call your receive handler with a plain Request signed by signHeaders to prove a forged or stale delivery never reaches your code, and a duplicate runs it once.

app/api/hooks/partner/route.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { signHeaders } from "mainsdk/webhooks";

const yaml = `schema: 1
project: prj_test
webhooks:
  inbound:
    partner: { preset: standard, secret: secret:PARTNER_SECRET, path: /api/hooks/partner }
`;
const secret = `whsec_${Buffer.from("partner-secret-bytes").toString("base64")}`;

describe("partner webhooks", () => {
  it("webhooks-jobs AC3 refuses a bad signature and a stale timestamp, and AC7 runs a duplicate once", async () => {
    const app = createTestApp({ yaml, secrets: { PARTNER_SECRET: secret } });
    let ran = 0;
    const POST = app.webhooks.receive("partner", () => void ran++);
    const body = JSON.stringify({ type: "partner.updated", data: {} });
    const now = Math.floor(app.clock.now() / 1000);
    const post = (key: string, id: string, at: number) =>
      POST(new Request("http://localhost/api/hooks/partner", {
        method: "POST",
        body,
        headers: signHeaders([key], id, { timestampSeconds: at, body }),
      }));

    const wrongKey = `whsec_${Buffer.from("not-the-secret").toString("base64")}`;
    assert.equal((await post(wrongKey, "msg_1", now)).status, 400);
    assert.equal((await post(secret, "msg_2", now - 360)).status, 400);
    assert.equal(ran, 0);

    assert.equal((await post(secret, "msg_3", now)).status, 200);
    assert.equal((await post(secret, "msg_3", now)).status, 200);
    assert.equal(ran, 1);
  });
});

For outbound deliveries, pass respond to createTestApp to answer the requests webhooks.emit() sends, read them from app.sent, and move through the retry schedule with await app.clock.tick("6m").

Security and privacy

  • Outbound deliveries are signed per Standard Webhooks, so receivers can verify them with any standard library.
  • Inbound signatures and timestamps are checked over the raw body before your code runs; replays outside the window are refused and logged without the body.
  • Signing keys are secret: references and never appear in code or logs. A literal secret in main.yaml fails mainsdk check.
  • Inbound endpoints appear in mainsdk discover (kind webhook-inbound). Listing them in the seal and mainsdk seal diff is planned.
  • The delivery log keeps status, response status and duration for each attempt, never request or response bodies.
  • Delivery is at least once with idempotent effects; exactly-once delivery across providers is not promised.

FAQ

Should I use webhooks.receive("stripe", …) or pay.on() for payments? Use pay.on() for payment events; it gives you typed payment events on the same verification.

What do I do when the sender's handler call times out? Return fast. Hand slow work to jobs.run() from your handler.

Do I get a delivery log in production? Yes, the delivery log is part of webhooks.emit() on every plan.

What is not decided yet? Whether the delivery log moves with a driver swap, since only the built-in engine exists today.

Reference

CallWhat it does
webhooks.receive(name, handler)Serve a declared inbound endpoint. The returned route handler checks the signature before handler runs
webhooks.handle(req, { remote? })Route a request to the inbound endpoint declared for its path. It resolves to null when no endpoint is mounted there, so a catch-all route can fall through. remote is the caller's address, used for the dev replay check
webhooks.emit(endpoint, event, { idempotencyKey? })Send one signed event to a URL and return the delivery id once the delivery is queued
webhooks.send(type, data, { idempotencyKey? })Queue a delivery to every enabled customer endpoint of the current tenant subscribed to type; resolves to { deliveries }
new Webhooks(inbound, outbound)The interface itself: the receiver and the sender behind one object. mainsdk builds it for you from main.yaml
PRESETS (constant)Every inbound preset by name, each with its replay tolerance (null when the provider signs no timestamp) and a verify function. The table above lists them
newSecret()A new endpoint secret: whsec_ followed by 32 random bytes in base64
signHeaders(secrets, id, { timestampSeconds, body })The webhook-id, webhook-timestamp and webhook-signature headers of a signed delivery, with one v1 signature for each secret, so a rotation can sign with both the new and the old secret

Development replay helpers

mainsdk webhooks is built on these functions. They read and write files under the project's .mainsdk/ folder, and they're exported for tools that need the same recordings.

CallWhat it does
devPaths(dir)The paths of the recorded log (.mainsdk/webhooks/inbound.jsonl), the replay key (.mainsdk/webhooks/replay.key) and the running dev server's file (.mainsdk/dev.json) under a project folder
readDeliveries(dir, now)The recorded deliveries, newest first, without those older than 7 days. A torn last line from a killed process is skipped
replayKey(dir, create)The replay key, made on first use when create is true; undefined when there is none and create is false
replaySignature(key, deliveryId, { body })The signature a replay request carries: the hex HMAC-SHA256 of the delivery id, a dot and the body, under the replay key

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet