Skip to content

11. Being called by the internet

the import webhook — mode: webhook, HMAC signatures over the raw body, dedupe by the sender's event id.

So far everything called your functions for you — a page, a schedule, another function. A webhook reverses it: a partner feed pushes assets into the shelf, signed in to nobody. The function is the whole trust boundary.

The function

examples/config/shelf/main/_functions/import/function.yaml

# A webhook: a partner feed pushes assets into the shelf. The sender has no
# session, so invoke allows anonymous calls and the function itself is the
# only check — it verifies the signature before anything else.
#
# `admin: true` because creating assets is a member's right, not a stranger's.
# The signing secret is set with
#   backd secret set --realm shelf --database main --name IMPORT_WEBHOOK_SECRET
mode: webhook
invoke: "true"
admin: true
secrets: [IMPORT_WEBHOOK_SECRET]
  • mode: webhook — ctx.input is gone; ctx.request holds the raw { body, headers }, and the function returns { status, body } itself.
  • invoke: "true" — anonymous calls must reach it; the sender has no session and never will.
  • secrets: — IMPORT_WEBHOOK_SECRET is shared with the sender out of band, stored per-database, never in git.

examples/config/shelf/main/_functions/import/index.ts

// import: handles "asset pushed" events from a feed. The feed 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.
//
// Imported assets arrive as drafts — published_at is the publish function's
// alone, even for a trusted sender.
import { verifySignature } from "../lib/signature.ts";
import type { Context } from "../lib/types.ts";

type ImportEvent = { event_id: string; title: string; kind?: string; url?: string; body?: string; tags?: 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.IMPORT_WEBHOOK_SECRET, body, headers["x-signature"]))) {
    return { status: 400, body: "invalid signature" };
  }

  // 2. Shape the event; the body is untrusted JSON, so fields are whitelisted.
  const event = JSON.parse(body) as ImportEvent;
  if (!event.event_id || !event.title) {
    return { status: 422, body: "need event_id and title" };
  }

  // 3. Deduplicate by the feed's own event id: record the event FIRST. The
  //    unique index on imports.event_id refuses a second insert with a 409,
  //    so a retried delivery stops here — before it can make a second asset.
  const db = ctx.admin.db("main");
  let record;
  try {
    record = await db.collection("imports").create({ event_id: event.event_id });
  } catch (err) {
    if ((err as { status?: number }).status === 409) return { status: 200, body: "already processed" };
    throw err;
  }

  const doc: Record<string, unknown> = { title: event.title, kind: event.kind === "note" ? "note" : "link" };
  if (event.url) doc.url = event.url;
  if (event.body) doc.body = event.body;
  doc.tags = [...(event.tags ?? []), "imported"];
  const asset = await db.collection("assets").create(doc);
  await db.collection("imports").patch(record.id, { asset_id: asset.id });

  return { status: 200, body: "ok" };
}

Three steps, in order:

  • Verify the sender against the exact bytes it signed — x-signature: sha256=<hex> is an HMAC of the raw body (lib/signature.ts, the same file the workshop uses).
  • Dedupe by the sender’s own event id — providers retry until they get a 2xx, so “already processed” is a normal answer. The function records the event first: the unique index on imports.event_id makes the second delivery a cheap 409 before any asset is created (creating the asset first would leave a duplicate behind).
  • Then, and only then, act — fields are whitelisted out of the untrusted JSON, and the asset lands as a draft: published_at is the publish function’s alone, even for a trusted feed (Webhooks).

Create the function and the imports collection (a record of event ids already handled), plus the shared helper:

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/import/function.yaml main/_functions/import/index.ts main/_functions/lib/signature.ts main/_functions/lib/signature.test.ts main/imports/schema.json main/imports/indexes.json main/imports/collection.yaml; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done

Rebuild and restart as before.

Feeding it

Set the shared secret once — secrets are written through the admin API (the operator’s $OP from chapter 4), never kept in config/:

curl -s -X PUT http://localhost:8080/v1/shelf/_admin/secrets/IMPORT_WEBHOOK_SECRET \
  -H "Authorization: Bearer $OP" -H 'Content-Type: application/json' \
  -d '{"database":"main","value":"dev-secret"}'     # 204

(Pushing before the secret exists answers 500 secret_missing — the function declares it in secrets: and backd refuses to run it without.) Then run the fake feed, which signs like a real provider. It is a small Node script (Node 20 or newer; or run it in a container with docker run --rm --network host -v "$PWD/app":/a:Z docker.io/library/node:22 node /a/push.js …):

curl -fsSL https://fernandezvara.github.io/backd/tutorial/app/push.js -o app/push.js
node app/push.js http://localhost:8080 dev-secret "Title" https://example.com

push.js computes sha256=<hmac> over the body and POSTs — what any provider does, minus the account.

Why no handler test

createContext fakes ctx.input, ctx.db, ctx.call, ctx.email — but not ctx.request, which webhooks alone get (the workshop’s payment_webhook ships untested for the same reason). What is testable is the crypto: signature.test.ts proves sign/verifySignature agree, and that a tampered byte or a wrong secret is refused — which is the part a bad actor probes.

You should see

  • node push.js … answers 200 ok — a new draft lands in assets. A webhook has no user, so the draft has no owner (_meta.owner is empty) and no member’s own-drafts rule matches; the curator’s read rule from chapter 5 does, so it shows up in the curator’s Review view, marked imported (no owner), ready to publish.
  • The same event_id sent twice answers 200 already processed — one imports doc, one asset.
  • A wrong x-signature answers 400 invalid signature; missing event_id/title, 422.
  • deno test covers the signature round-trip.

Next: chapter 12 — running it: API keys, the audit feed, and the hardening checklist.

Edit this page · Last updated 08 Oct 2026