---
title: "Send and receive webhooks"
description: "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."
section: "guides"
status: "Preview"
spec: "webhooks-jobs"
canonical: "https://mainsdk.dev/docs/guides/send-and-receive-webhooks"
updated: "2026-10-28"
---

# 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: Preview · Canonical: https://mainsdk.dev/docs/guides/send-and-receive-webhooks · 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

```yaml title="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

```ts title="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](https://mainsdk.dev/docs/guides/run-background-jobs) 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

```ts title="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`.

```ts title="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

| 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

```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

- [Webhooks](https://mainsdk.dev/docs/interfaces/webhooks): every preset, the delivery log and customer-managed endpoints.
- [Run work in the background](https://mainsdk.dev/docs/guides/run-background-jobs): what to hand the slow work to.
- [Take a payment](https://mainsdk.dev/docs/guides/take-a-payment): typed payment webhooks with `pay.on()`.
