---
title: "Webhooks"
description: "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."
section: "interfaces"
status: "Preview · ships as a stable API"
spec: "webhooks-jobs"
canonical: "https://mainsdk.dev/docs/interfaces/webhooks"
updated: "2026-10-28"
---

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

Status: Preview · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/webhooks · 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

- Status: Preview · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/webhooks-jobs.md
- Category: Events, jobs and webhooks

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? })`

In main.yaml:

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

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

Pick one in main.yaml with `webhooks.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |
| Svix | `svix` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-HOOK-0011` | check | A `twilio` inbound entry has no `public-url` |
| `MAIN-HOOK-0002` | runtime | Incoming webhook with a wrong signature or a timestamp too old (400) |
| `MAIN-HOOK-0001` | runtime |  |
| `MAIN-HOOK-0003` | runtime | `webhooks.receive` names neither a key under `webhooks.inbound` nor a preset, or an entry names an unknown preset |
| `MAIN-HOOK-0004` | runtime | 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` | runtime | The tenant already has `webhooks.endpoints.max-per-tenant` endpoints (409) |
| `MAIN-HOOK-0006` | runtime | An event type that isn't under `webhooks.endpoints.events` (400) |
| `MAIN-HOOK-0007` | runtime | An endpoint was disabled after `disable-after` failures; an audit event and `webhook.endpoint.disabled` are written |
| `MAIN-HOOK-0008` | runtime | A test or redeliver to a disabled endpoint (409) |
| `MAIN-HOOK-0009` | runtime | No endpoint or delivery with that id in the current tenant (404) |
| `MAIN-HOOK-0010` | runtime | An endpoints call without the `webhooks.manage` permission (403) |
| `MAIN-HOOK-0012` | runtime | `mainsdk webhooks replay` refused: not a dev environment, no `mainsdk dev` running, or an unknown id (exit 2) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| webhooks@1 contract | 1 | 0.3 | `webhooks-jobs#contract` |
| Shared idempotency record | 1 | 0.3 | `webhooks-jobs#idempotency-store` |
| Signed delivery with retries and a delivery log | 1 | 0.3 | `webhooks-jobs#emit` |
| Verified receive | 1 | 0.3 | `webhooks-jobs#receive` |
| Built-in and Svix drivers | 1 | 0.3 | `webhooks-jobs#drivers-webhooks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

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

```yaml title="main.yaml"
webhooks:
  inbound:
    github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /api/webhooks/github }
```

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

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

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

> **Note:** The built-in delivery engine is the only driver built today. `svix` is planned (spec `webhooks-jobs`). Inbound webhooks need no driver.

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

| 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()`](https://mainsdk.dev/docs/interfaces/pay), which reads `pay.<provider>.webhook` and gives you typed payment events.

## Examples

### Emit a signed webhook with retries

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

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

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

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

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

| 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](https://mainsdk.dev/docs/interfaces/jobs): move slow work out of the handler.
- [Send and receive webhooks](https://mainsdk.dev/docs/guides/send-and-receive-webhooks): the same calls as a walkthrough.
- [Payments](https://mainsdk.dev/docs/interfaces/pay): verified payment webhooks with `pay.on()`.
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets): store signing keys.
