MainSDK
Search⌘K
How-to

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.

Preview

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

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:NAME from the environment variable NAME.
  • A signing key of your own for outbound webhooks, such as whsec_ and 32 random bytes in base64.

1. Declare the endpoints

main.yaml
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

app/api/webhooks/github/route.ts
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

app/orders/notify.ts
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:

bash
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 check

Without --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.

app/webhooks.test.ts
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

bash
npx mainsdk check
node --test app/webhooks.test.ts

A 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

CodeCauseFix
MAIN-HOOK-0002Wrong signature, a timestamp outside the window, or a body over 1 MiBCheck 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 presetDeclare it under webhooks.inbound with one of the presets
MAIN-HOOK-0011A twilio entry has no public-urlSet public-url to your app's public address
MAIN-HOOK-0001webhooks.emit has no signing secret, or the type or URL is invalidSet webhooks.secret, use a type like order.shipped and a full https URL
MAIN-HOOK-0012mainsdk webhooks replay outside dev, with no dev server, or with an unknown idRun it under mainsdk dev with a recorded id
MAIN-CONFIG-0004A literal secret in main.yamlWrite secret:NAME and move the value to your secrets manager

For agents

text
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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet