Send and receive webhooks
Verify GitHub or Stripe webhooks before your code runs, run each delivery once, and send your own signed webhooks with retries and a delivery log.
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.
You will receive a signed webhook so that forged and replayed requests never reach your code, hand its slow work to a job, and send your own signed webhook to a partner with retries. It takes about 20 minutes.
You need
- A MainSDK app with
main.yaml. - A signing secret from the sender (for GitHub, the secret you set on the webhook) in your secrets manager. In development, MainSDK reads
secret:NAMEfrom the environment variableNAME. - A signing key of your own for outbound webhooks, such as
whsec_and 32 random bytes in base64.
1. Declare the endpoints
network:
allow: [hooks.partner.dev] # the hosts webhooks.emit may call
webhooks:
secret: secret:WEBHOOK_SIGNING_KEY # signs the webhooks you send
retries: { max: 8, window: 24h }
inbound:
github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /api/webhooks/github }The preset says how the sender signs (stripe, github, slack, shopify, twilio, razorpay, standard, mailgun, ses, postmark). A secret is always a secret: reference; a literal value fails mainsdk check with MAIN-CONFIG-0004. To rotate without downtime, write secret: [secret:NEW, secret:OLD]: a signature that matches either passes.
2. Receive a webhook
import { jobs, webhooks } from "mainsdk";
export const POST = webhooks.receive("github", async (body, meta) => {
// body is the parsed JSON; meta.id is GitHub's delivery id
await jobs.run("deploys.record", { delivery: meta.id, payload: body }, { idempotencyKey: meta.id });
});webhooks.receive() returns a route handler that takes a standard Request. The signature and timestamp are checked over the raw body before the handler runs. A forged request gets 400 with MAIN-HOOK-0002 and is logged without its body.
Keep the handler short: record or queue the work and return, so the sender gets its 200 quickly. Here the slow work goes to a job keyed by the delivery id.
3. Check that duplicates run once
Senders retry, and sometimes send the same event twice. MainSDK passes the provider's delivery id (for GitHub, X-GitHub-Delivery) to the idempotency record, so the second delivery gets 200 without calling your handler. You do not need your own dedupe table. If your handler throws, the sender gets a 500, nothing is marked done, and its retry runs the handler again.
4. Send a signed webhook
import { webhooks } from "mainsdk";
export async function notifyPartner(order: { id: string }) {
return webhooks.emit(
"https://hooks.partner.dev/in",
{ type: "order.shipped", data: { orderId: order.id } },
{ idempotencyKey: `order.shipped:${order.id}` },
);
}emit queues the delivery and returns its id; it does not wait for the partner. Each request carries webhook-id, webhook-timestamp and webhook-signature, so the partner can verify it with any Standard Webhooks library. If the endpoint answers anything but 2xx, MainSDK sends the same webhook-id again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, up to retries.max attempts inside retries.window. Read a delivery with webhooks.delivery(id): it lists each attempt with its status and response status, never a body.
5. Replay a delivery in development
Under mainsdk dev, every inbound delivery is recorded in .mainsdk/ for 7 days or the last 500. Replay one to rerun your handler without going back to the sender:
npx mainsdk webhooks list --json
npx mainsdk webhooks replay <id>
npx mainsdk webhooks replay <id> --same-id # keep the delivery id, to test the duplicate checkWithout --same-id the handler runs again with a new delivery id. With it, you get 200 and the handler does not run, which is the duplicate check working. Outside dev, or with no dev server running, the command exits 2 with MAIN-HOOK-0012.
6. Test it
createTestApp gives you a fake network and clock. Sign a request yourself to test the receiver, and read what emit sent from app.sent.
import { describe, it } from "node:test";
import { createHmac } from "node:crypto";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
const secrets = {
WEBHOOK_SIGNING_KEY: `whsec_${Buffer.from("signing-key-bytes").toString("base64")}`,
GITHUB_WEBHOOK_SECRET: "gh-secret",
};
describe("webhooks", () => {
it("webhooks-jobs AC3 and AC7: refuses a forged delivery and runs a duplicate once", async () => {
const app = createTestApp({ main: "./main.yaml", secrets });
let ran = 0;
const POST = app.webhooks.receive("github", () => void ran++);
const body = JSON.stringify({ action: "opened" });
const post = (key: string) =>
POST(new Request("http://localhost/api/webhooks/github", {
method: "POST",
body,
headers: {
"x-hub-signature-256": `sha256=${createHmac("sha256", key).update(body).digest("hex")}`,
"x-github-delivery": "gh-1",
},
}));
assert.equal((await post("wrong")).status, 400);
assert.equal(ran, 0);
assert.equal((await post("gh-secret")).status, 200);
assert.equal((await post("gh-secret")).status, 200);
assert.equal(ran, 1);
});
it("webhooks-jobs AC2: retries a failing endpoint with the same webhook-id", async () => {
let calls = 0;
const app = createTestApp({
main: "./main.yaml",
secrets,
respond: () => new Response(null, { status: ++calls < 4 ? 500 : 200 }),
});
const id = await app.webhooks.emit("https://hooks.partner.dev/in", { type: "order.shipped", data: { orderId: "o1" } });
for (const wait of ["10s", "6m", "31m"]) await app.clock.tick(wait); // the retry schedule
const ids = new Set(app.sent.map((r) => r.headers.get("webhook-id")));
assert.equal(app.sent.length, 4);
assert.equal(ids.size, 1);
const delivery = await app.webhooks.delivery(id);
assert.equal(delivery?.status, "delivered");
assert.equal(delivery?.attempts?.length, 4);
});
});Verify
npx mainsdk check
node --test app/webhooks.test.tsA pass prints mainsdk check: passed (dev) and both tests pass: the forged request got 400 and never reached your handler, the duplicate ran it once, and the failing endpoint was retried until it delivered, with one webhook-id across four attempts.
If it fails
| Code | Cause | Fix |
|---|---|---|
MAIN-HOOK-0002 | Wrong signature, a timestamp outside the window, or a body over 1 MiB | 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 | Declare it under webhooks.inbound with one of the presets |
MAIN-HOOK-0011 | A twilio entry has no public-url | Set public-url to your app's public address |
MAIN-HOOK-0001 | webhooks.emit has no signing secret, or the type or URL is invalid | Set webhooks.secret, use a type like order.shipped and a full https URL |
MAIN-HOOK-0012 | mainsdk webhooks replay outside dev, with no dev server, or with an unknown id | Run it under mainsdk dev with a recorded id |
MAIN-CONFIG-0004 | A literal secret in main.yaml | Write secret:NAME and move the value to your secrets manager |
For agents
Goal: receive <provider> webhooks safely and send signed webhooks to <partner host>.
Touch: main.yaml (webhooks.inbound, webhooks.secret, network.allow), one route file that exports webhooks.receive(...), the module that calls webhooks.emit(), one test.
Do: use the provider's preset; keep every secret as a secret: reference; hand slow work to jobs.run() keyed by meta.id.
Do not: parse or verify signatures by hand, add your own dedupe table, put a secret literal in main.yaml, or add the partner's host to network.allow without telling the owner.
Verify: npx mainsdk check, then node --test on the webhook test file.
Stop and ask if: the provider is not one of the presets, or its signing secret is not available yet.Next
- Webhooks: every preset, the delivery log and customer-managed endpoints.
- Run work in the background: what to hand the slow work to.
- Take a payment: typed payment webhooks with
pay.on().
Was this page helpful?