Webhooks
Receive callbacks from other services and answer them yourself.
mode: webhook receives a callback from another service — Stripe, GitHub, and the like — and answers it directly: ctx carries the raw request instead of parsed input, and the function sets the response’s status, headers and body itself instead of returning a JSON value backd interprets.
Payment provider example: payment_webhook verifies the sender’s HMAC signature, deduplicates the provider’s retries by event id, and marks the order paid.
examples/config/workshop/main/_functions/payment_webhook/function.yaml
# A webhook: another service (your payment provider) calls this URL. The
# sender has no backd session, so `invoke` must allow anonymous callers and
# the function itself is the only check: it verifies the signature.
#
# `admin: true` because marking an order paid is not something a customer
# rule allows. The signing secret is set with
# backd secret set --realm workshop --database main --name PAYMENT_WEBHOOK_SECRET
mode: webhook
invoke: "true"
admin: true
secrets: [PAYMENT_WEBHOOK_SECRET]examples/config/workshop/main/_functions/payment_webhook/index.ts
// payment_webhook: handles "payment.succeeded" events from a payment
// provider. The provider signs the raw body (x-signature: sha256=<hex>) and
// retries any delivery it didn't get a 2xx for, so the same event can
// arrive more than once.
import { verifySignature } from "../lib/signature.ts";
import type { Context } from "../lib/types.ts";
type PaymentEvent = { id: string; type: string; order_id: string };
export default async function handler(ctx: Context) {
// 1. Verify the sender, against the exact bytes it signed.
const { body, headers } = ctx.request;
if (!(await verifySignature(ctx.secrets.PAYMENT_WEBHOOK_SECRET, body, headers["x-signature"]))) {
return { status: 400, body: "invalid signature" };
}
// 2. Deduplicate by the provider's own event id: the unique index on
// events.event_id refuses a second insert with a 409.
const event = JSON.parse(body) as PaymentEvent;
const db = ctx.admin.db("main");
try {
await db.collection("events").create({ event_id: event.id, type: event.type, order_id: event.order_id });
} catch (err) {
if ((err as { status?: number }).status === 409) return { status: 200, body: "already processed" };
throw err;
}
// 3. Handle it.
if (event.type === "payment.succeeded") {
try {
await db.collection("orders").patch(event.order_id, { status: "paid" });
} catch (err) {
if ((err as { status?: number }).status === 404) return { status: 404, body: "unknown order" };
throw err;
}
}
return { status: 200, body: "ok" };
}examples/config/workshop/main/_functions/lib/signature.ts
// HMAC-SHA256 signatures for webhooks: the sender signs the raw request
// body with a secret you share, and sends it as `x-signature: sha256=<hex>`.
const encoder = new TextEncoder();
async function hmacHex(secret: string, body: string): Promise<string> {
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
/** What a sender puts in the header (used by tests and by the docs' curl example). */
export async function sign(secret: string, body: string): Promise<string> {
return `sha256=${await hmacHex(secret, body)}`;
}
/** True when `header` is the signature of `body` under `secret`. Compares in constant time. */
export async function verifySignature(secret: string, body: string, header: string | undefined): Promise<boolean> {
if (!header) return false;
const expected = encoder.encode(await sign(secret, body));
const given = encoder.encode(header);
if (expected.length !== given.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ given[i];
return diff === 0;
}The provider signs the raw body with the shared secret and sends the result in a header. You can play the provider with openssl to try it:
backd secret set --realm workshop --database main --name PAYMENT_WEBHOOK_SECRET # the shared secret
BODY='{"id": "evt_1", "type": "payment.succeeded", "order_id": "d3c9ljp8hc2g00b6s1m0"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'whsec_test' | sed 's/^.* //')"
curl -X POST https://api.example.com/v1/workshop/main/_func/payment_webhook -H "x-signature: $SIG" -d "$BODY"
# → 200 ok (the same request again: 200 already processed; a wrong signature: 400 invalid signature)
Real providers each have their own signature scheme (Stripe signs <timestamp>.<body> and sends Stripe-Signature: t=…,v1=…, for example): follow theirs, but keep the same three steps: verify against the raw body, deduplicate by the provider’s event id (a unique index on it makes a repeated insert fail), then act.
ctx.request.headersis everything the sender sent, including anyAuthorizationorCookieheader, all chosen by the sender. Don’t log the headers, don’t forward them, and don’t trustX-Forwarded-Foror similar. With no credentials, the call is anonymous (ctx.userisnull); see who is calling.ctx.requestreplacesctx.input:ctx.request.bodyis the raw request body as a string (never parsed as JSON, so a signature check sees exactly the bytes the sender signed — parsing and re-serializing first would break it if the byte-for-byte JSON ever differs, e.g. key order or whitespace);ctx.request.headersis a plain object of lower-case header names to string values (several values for the same header are folded into one, comma-joined, as HTTP itself treats them). Request bodies aren’t required to be JSON at all — the caller’sContent-Typeisn’t checked.- The function’s return value is the response, not a JSON body backd wraps:
{ status, body, headers? }.statusbecomes the HTTP status (defaults to200if left out or invalid);bodyis written as-is (defaults to empty);headersare set on the response (Content-Typedefaults totext/plain; charset=utf-8unless the function sets its own).input.schema.json,output.schema.jsonandctx.errordon’t apply — there’s no caller-facing error envelope to shape, only the response the function itself constructs. invokemust allow anonymous callers (checked at startup, not just documented — see above): a webhook sender has nobackdcredential, so the function itself is the only thing that can tell a genuine request from a forged one. That’s exactly what step 1 above does; skipping it means anyone who finds the URL can trigger the function.- A full concurrency limit answers
503, not429(unlikesync) — Stripe, GitHub, and most webhook senders retry automatically on a 5xx, so this makes a temporary limit resolve itself; a4xxtypically means “don’t bother resending.” - Secrets, network access,
ctx.db/ctx.admin.db, logs, and invocation history all work exactly as forsync.