Webhooks
webhooksSend webhooks signed per Standard Webhooks with retries and a delivery log, and verify incoming ones from Stripe, GitHub and more before your handler runs.
webhooks-jobsFree in your app, Apache-2.0Status 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.
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 }import { webhooks } from "mainsdk";
export const POST = webhooks.receive("github", async (event) => {
await handlePush(event.data); // runs only after the signature is checked
});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);
});
});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? })
Drivers
2 drivers. Select one to see its line in main.yaml.
webhooks:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-HOOK-0011 | Checkmainsdk check, before deploy | A twilio inbound entry has no public-url |
MAIN-HOOK-0002 | RuntimeError responses and logs | Incoming webhook with a wrong signature or a timestamp too old (400) |
MAIN-HOOK-0001 | RuntimeError responses and logs | See the spec |
MAIN-HOOK-0003 | RuntimeError responses and logs | webhooks.receive names neither a key under webhooks.inbound nor a preset, or an entry names an unknown preset |
MAIN-HOOK-0004 | RuntimeError responses and logs | A 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-0005 | RuntimeError responses and logs | The tenant already has webhooks.endpoints.max-per-tenant endpoints (409) |
MAIN-HOOK-0006 | RuntimeError responses and logs | An event type that isn't under webhooks.endpoints.events (400) |
MAIN-HOOK-0007 | RuntimeError responses and logs | An endpoint was disabled after disable-after failures; an audit event and webhook.endpoint.disabled are written |
MAIN-HOOK-0008 | RuntimeError responses and logs | A test or redeliver to a disabled endpoint (409) |
MAIN-HOOK-0009 | RuntimeError responses and logs | No endpoint or delivery with that id in the current tenant (404) |
MAIN-HOOK-0010 | RuntimeError responses and logs | An endpoints call without the webhooks.manage permission (403) |
MAIN-HOOK-0012 | RuntimeError responses and logs | mainsdk 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| webhooks@1 contract | Phase 1 | 0.3 | webhooks-jobs#contract |
| Shared idempotency record | Phase 1 | 0.3 | webhooks-jobs#idempotency-store |
| Signed delivery with retries and a delivery log | Phase 1 | 0.3 | webhooks-jobs#emit |
| Verified receive | Phase 1 | 0.3 | webhooks-jobs#receive |
| Built-in and Svix drivers | Phase 1 | 0.3 | webhooks-jobs#drivers-webhooks |
Quick start
Declare the inbound endpoint and its secret, then receive it in a route handler.
webhooks:
inbound:
github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /api/webhooks/github }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
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| Key | Type | What it does |
|---|---|---|
webhooks.secret | secret reference | Signing key for webhooks.emit(); without it emit fails with MAIN-HOOK-0001 |
webhooks.retries.max | integer | Maximum delivery attempts (default 8) |
webhooks.retries.window | duration | Time window for retries (default 24h) |
webhooks.timeout | duration | Time 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>.preset | string | How the sender signs: stripe, github, slack, shopify, twilio, razorpay, standard, mailgun, ses, postmark or getqr |
webhooks.inbound.<name>.secret | secret reference or list | The 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>.path | string | The path the adapter mounts and exempts from CSRF |
webhooks.inbound.<name>.public-url | string | Required for twilio, so the signed URL is rebuilt from it and never from the Host header |
webhooks.inbound.<name>.topics | list | For ses, the SNS topics a subscription may confirm |
network.allow | list | The 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 replayto 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.
| Preset | Replay window | Delivery id for duplicates |
|---|---|---|
stripe | 5 minutes | event id in the body |
github | none | X-GitHub-Delivery |
slack | 5 minutes | event_id in the body |
shopify | 5 minutes when X-Shopify-Triggered-At is sent | X-Shopify-Webhook-Id |
twilio | none | I-Twilio-Idempotency-Token |
razorpay | none | x-razorpay-event-id |
standard (Svix, Replicate, Resend, Clerk) | 5 minutes | webhook-id |
mailgun | 5 minutes | token |
ses | 1 hour | MessageId |
postmark | none | MessageID |
getqr | 5 minutes | event 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
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.
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.
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 checkA 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.
webhooks:
endpoints:
events: [order.created, order.shipped]
max-per-tenant: 20
disable-after: { failures: 50, for: 5d }
keep-deliveries: 30dThese 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.
| Code | When | Fix |
|---|---|---|
MAIN-HOOK-0001 | webhooks.emit has no signing secret, or an event type or URL is invalid | Set webhooks.secret: secret:WEBHOOK_SIGNING_KEY, use a type like order.shipped and a full https URL |
MAIN-HOOK-0002 | An 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-0003 | webhooks.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-0011 | A twilio inbound entry has no public-url (check) | Set public-url to your app's public address |
MAIN-HOOK-0012 | mainsdk 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.
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 inmain.yamlfailsmainsdk check. - Inbound endpoints appear in
mainsdk discover(kindwebhook-inbound). Listing them in the seal andmainsdk seal diffis 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
| Call | What 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.
| Call | What 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
- Events and background jobs: move slow work out of the handler.
- Send and receive webhooks: the same calls as a walkthrough.
- Payments: verified payment webhooks with
pay.on(). - Secrets: store signing keys.
Was this page helpful?