Skip to content

AdvancedWebhooks

Webhooks

Declare incoming webhooks as you declare endpoints: a schema for the body and the sender's signature checked before your handler runs — Stripe, GitHub, Standard Webhooks or your own — shown in Studio's Timeline and delivered by a mock.

5 min readboth modes

Declaring a webhook

A webhook is a call made to your app. Declare it as you declare an endpoint: the schema its body must pass, and the signature its sender signs it with. defineWebhook checks both before your handler sees the delivery.

lib/webhooks.ts
import { defineWebhook, stripeSignature } from "@noverax/courier/webhooks"
import { z } from "zod"

export const stripeEvents = defineWebhook({
  name: "stripe",
  signature: stripeSignature({ secret: () => process.env.STRIPE_WEBHOOK_SECRET }),
  body: z.object({
    id: z.string(),
    type: z.string(),
    data: z.object({ object: z.record(z.string(), z.unknown()) }),
  }),
  event: (body) => body.type,
})

handler turns it into the function a route mounts — a delivery in, the answer out — with the body typed by its schema:

app/api/webhooks/stripe/route.ts
import { stripeEvents } from "@/lib/webhooks"

export const POST = stripeEvents.handler(async (event) => {
  if (event.type === "invoice.paid") await markPaid(event.data.object)
})

async function markPaid(invoice: Record<string, unknown>) {
  console.log("paid", invoice.id)
}

In Hono, app.post("/webhooks/stripe", (c) => POST(c.req.raw)); in Express, through toWebRequest and sendWebResponse from @noverax/courier/node, as the MCP handler mounts. Mount it where the body reaches it untouched: the signature is checked over the body exactly as it came, so a framework must not parse it first.

What a delivery passes

In order, and nothing reaches your handler until all of it has:

CheckRefused with
The body is no larger than maxBodyBytes (default: 1 MB), counted as it arrives — a larger one is refused at the chunk that crosses the limit, never held whole413
The signature matches the body under the webhook's secret, and the delivery is recent401
The body is JSON400
The body passes the schema422, with each place the schema refused

The signature is compared in time that does not depend on where it differs, so a caller learns nothing from how long a refusal takes. A refusal says what failed and nothing more. A webhook whose secret is missing — or, for Standard Webhooks, not whsec_ and base64 — refuses every delivery with 500 and says why in the log, rather than take an unsigned one.

Your handler answering nothing answers 204. A Response it returns is answered as it is — 202 for work queued. One that throws is answered 500, which the sender retries.

Signatures

SchemeSendersWhat is checked
stripeSignature({ secret })StripeStripe-Signature: each v1 HMAC-SHA-256 of <timestamp>.<body>; older than five minutes is a replay
githubSignature({ secret })GitHubX-Hub-Signature-256: sha256= and the HMAC-SHA-256 of the body
standardWebhookSignature({ secret })Svix, Clerk, Resend and every Standard Webhooks senderwebhook-id, webhook-timestamp, webhook-signature — or Svix's svix-id, svix-timestamp, svix-signature: base64 HMAC-SHA-256 of <id>.<timestamp>.<body> under the whsec_ key, several while a secret rotates; older than five minutes is a replay
hmacSignature({ secret, header, encoding, prefix })A service of your ownOne header with the HMAC-SHA-256 of the body, in hex or base64, after a prefix

secret is the text itself, or a function that reads it when a delivery comes — from the environment, a secret store. Every scheme runs on Web Crypto, so a webhook works on Node.js, Bun, Deno and an edge runtime alike. GitHub's scheme and one of your own sign no timestamp: keep the delivery ids you have handled if a replay matters. For a service you write, prefer Standard Webhooks.

In Studio

Each delivery shows in the Timeline as a call made to your app, marked webhook: the webhook's name as its source, the event as its method, the body as its variables once it has passed its signature, how your handler answered — or why the delivery was refused. Mode → Webhook shows them alone, in the Timeline and on the Waterfall.

Opened, a delivery is told in its own parts rather than a call's:

TabWhat it shows
OverviewThe webhook and the event, and three checks: the signature, the body against the schema, the handler's answer
ErrorWhere a refused delivery stopped — over the size limit, a signature that does not match, no secret to check one, a body that is not JSON, one the schema refused with each place, a handler that threw — and what usually fixes it
ResponseWhat the sender was answered: your handler's answer, or the refusal
HeadersThe headers the delivery came with, the signature hidden as a credential is: with the body, it would let anyone deliver it again
TimingThe checks — reading the body, the signature, the schema — then your handler
SnippetsThe delivery as a mock makes it again, with deliverWebhook and the webhook's own secret

A delivery is a call made to your app, not one it makes, so the Audit leaves deliveries out.

Delivering one from Studio

Deliver again in a delivery's inspector — or Deliver a webhook in the command palette, for one the Timeline has not shown — opens the delivery to send: the webhook, the route it goes to, and the event, which you can change first. Studio asks the app that defines the webhook to deliver it: the app signs the body with the webhook's own secret and sends it to the route as its sender would, so the handler checks it as it checks a real one, and it shows in the Timeline like every delivery. The secret never leaves the app.

  • Only an app attached with this project's Studio token is asked — a server runtime reads it from .courier/studio-token — and it delivers only to a route its deliveries already came to, or to an address on this machine: a body signed there and sent elsewhere would let that host deliver it to your app.
  • An app knows a webhook once the module that defines it has loaded. One that loads a route when it is first requested, as Next.js does in development, knows its webhooks once their routes have been requested.

Delivering one locally

deliverWebhook sends a delivery as its sender would — the body signed with the webhook's own secret and scheme — so the whole flow runs on your machine, with no provider and no tunnel. A mock delivers one when it answers the call that would make the provider send it:

courier.mocks.ts
import { defineMocks, defineMockServer, deliverWebhook } from "@noverax/courier-mock"
import { api } from "@/api/sources"
import { stripeEvents } from "@/lib/webhooks"

export default defineMocks({
  payments: defineMockServer({
    source: api,
    overrides: (mock) => {
      mock("invoices", "pay").handler(async ({ variables }) => {
        setTimeout(() => {
          void deliverWebhook(stripeEvents, {
            to: "http://localhost:3000/api/webhooks/stripe",
            body: { id: "evt_1", type: "invoice.paid", data: { object: { id: variables } } },
          })
        }, 100)
        return { status: "processing" }
      })
    },
  }),
})

In a test, give deliverWebhook a fetch that calls the handler directly, and check what it did. webhook.sign(body) gives the signing headers alone, for a client of your own.