Cookbook
Seven complete, tested recipes: a sync function as the caller, a privileged idempotent refund, a background report, a nightly cleanup, a daily digest, a payment webhook and an email delivery function for Postmark.
Every recipe here is a function of the workshop realm (examples/config/workshop in the repository): a tiny shop with orders, refunds, reports, digests and events. The code on this page is read straight from those files, and TestWorkshopExample runs every function for real (MongoDB, a real executor, a real worker), so what you read is what runs.
Run the workshop
The repository’s local stack serves the realm, and its backd runs a worker (serve --with-worker), so jobs and schedules work:
make example # docker compose up --build; the API is at https://localhost:8443
docker compose exec backd /backd bootstrap --realm workshop --email ops@example.com # first administrator
# (the workshop tour already offers an operator demo account, so this is only needed to use the CLI)
API=https://localhost:8443/v1/workshop
TOKEN=$(curl -s $API/_auth/signup -H 'Content-Type: application/json' \
-d '{"email": "ada@example.com", "password": "dev-p4ssw0rd!"}' | jq -r .token)
curl -s $API/main/orders -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"item": "widget", "quantity": 2, "amount": 1000, "status": "draft"}'
# → {"id": "d3c9ljp8hc2g00b6s1m0", "item": "widget", …}
The stack sets a dev-only BACKD_SECRETS_KEY, which payment_webhook needs because it declares a secret; the secret’s value is set later, in the webhook recipe. backd starts without it, and only that function answers 500 secret_missing until it is set.
The orders rules let a customer create and edit their own drafts and read their own orders; status is never theirs to change. Only functions move an order on.
A sync function that runs as the caller
order_total prices one of the caller’s orders. It reads with ctx.db, so the orders’ read rule decides what exists for this caller: someone else’s order is a 404, the same as reading it directly. The tax rate lives in one place, on the server.
examples/config/workshop/main/_functions/order_total/function.yaml
# A sync function that runs as the caller: ctx.db reads with the caller's
# own access rules, so a customer can only price their own orders.
invoke: "user != nil"examples/config/workshop/main/_functions/order_total/index.ts
// order_total: the amount a customer will be charged for one of their
// orders. The tax rule lives here, on the server, not in every client.
//
// ctx.db acts as the caller, so orders/collection.yaml decides what they may
// read: someone else's order (or one that doesn't exist) answers 404, the
// same as reading the collection directly would.
import { relay } from "../lib/relay.ts";
import type { Context, Doc } from "../lib/types.ts";
const TAX_RATE = 0.2;
export default async function handler(ctx: Context) {
const { order_id } = ctx.input as { order_id: string };
let order: Doc;
try {
order = await ctx.db("main").collection("orders").get(order_id);
} catch (err) {
relay(err, ctx.error);
}
if (order.status === "refunded") {
throw ctx.error(409, "order_refunded", "This order was refunded");
}
const subtotal = order.amount as number;
const tax = Math.round(subtotal * TAX_RATE);
return { order_id, subtotal, tax, total: subtotal + tax };
}curl -s $API/main/_func/order_total -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"order_id": "d3c9ljp8hc2g00b6s1m0"}'
# → {"order_id": "d3c9ljp8hc2g00b6s1m0", "subtotal": 1000, "tax": 200, "total": 1200}
See Writing a function, including the input and output schemas and why errors go through relay.
A privileged, atomic, idempotent change
refund is something no customer rule may allow, so it is a function with admin: true, restricted to staff, that changes the order and records the refund in one transaction. idempotency: required makes a retried request safe.
examples/config/workshop/main/_functions/refund/function.yaml
# A privileged, atomic change no customer rule may allow: mark the order
# refunded and record the refund together, or neither.
#
# - `admin: true` gives ctx.admin.db, full access recorded as this function.
# - Only staff may call it (API keys always may).
# - `idempotency: required`: a retried request with the same
# Idempotency-Key gets the first answer back instead of refunding twice.
admin: true
# It emails the customer through the realm's templates and delivery function.
email: true
# It may call these functions of the database, and no others. refund_receipt is
# internal and async: refund queues it and doesn't wait.
calls: [refund_receipt]
invoke: "user != nil && hasRole(user, 'staff')"
idempotency: requiredexamples/config/workshop/main/_functions/refund/index.ts
// refund: mark a paid order refunded and record the refund, in one
// transaction. `ifMatch` makes the order's patch fail if anyone changed the
// order since we read it, which aborts the whole batch: nothing is written.
// Afterwards it asks the internal refund_receipt function to write the receipt
// and emails the customer, through the realm's `refund-issued` template.
import { relay } from "../lib/relay.ts";
import type { Context, Doc } from "../lib/types.ts";
export default async function handler(ctx: Context) {
const { order_id, reason } = ctx.input as { order_id: string; reason?: string };
const db = ctx.admin.db("main");
let order: Doc;
try {
order = await db.collection("orders").get(order_id);
} catch (err) {
relay(err, ctx.error);
}
if (order.status !== "paid") {
throw ctx.error(409, "not_refundable", `An order that is ${order.status} can't be refunded`);
}
let refund: Doc;
try {
[, refund] = await db.batch([
{ op: "patch", collection: "orders", id: order_id, patch: { status: "refunded" }, ifMatch: order._meta.version },
{
op: "create",
collection: "refunds",
document: { order_id, amount: order.amount, reason: reason ?? "", refunded_by: ctx.user?.email ?? "api-key" },
},
]);
} catch (err) {
relay(err, ctx.error);
}
console.log(`refunded order ${order_id}`);
// The receipt is written by an internal function, as a job: the refund is
// already done, so a slow or failing receipt never undoes it. The key makes
// a retried refund queue the same job instead of a second one.
const job = await ctx.call("refund_receipt", { refund_id: refund.id, order_id, amount: order.amount }, { idempotencyKey: `receipt-${refund.id}` });
// The customer is told by email: a custom kind of the realm
// (email/refund-issued/), sent to the order's owner, a user of the realm. The
// recipient comes from the order, never from the request; a failure here
// (a limit, a template problem) must not undo or hide a refund that happened.
try {
await ctx.email.send({ kind: "refund-issued", to_user: order._meta.owner, data: { order_id, amount: (order.amount / 100).toFixed(2), reason: reason ?? "" } });
} catch (err) {
console.log(`the customer was not emailed: ${err instanceof Error ? err.message : err}`);
}
return { refund_id: refund.id, order_id, amount: order.amount, receipt_job: job.id };
}curl -s $API/main/_func/refund -H "Authorization: Bearer $STAFF_TOKEN" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: refund-d3c9ljp8hc2g00b6s1m0' -d '{"order_id": "d3c9ljp8hc2g00b6s1m0", "reason": "damaged"}'
# → {"refund_id": "d3c9lq38hc2g00b6s1o0", "order_id": "d3c9ljp8hc2g00b6s1m0", "amount": 1000, "receipt_job": "d3c9lq38hc2g00b6s1p0"}
# the same request again → the same answer, and nothing is refunded twice
# without an Idempotency-Key → 400 idempotency_key_required; as a customer → 403; an unpaid order → 409 not_refundable
Give someone the staff role with backd user add-role --realm workshop --email … --role staff. Read more: idempotency, batch writes.
A background report
export_orders builds a CSV of the caller’s orders. As an async function it answers 202 with a job; a worker runs it; the report lands in reports, owned by the customer. Running the same job twice writes one report.
examples/config/workshop/main/_functions/export_orders/function.yaml
# An async function: the call answers 202 with a job, and a worker runs the
# function in the background. Use it for work that may take longer than a
# caller should wait, or that must survive the caller going away.
#
# It runs as the customer who asked (ctx.db), so it can only export their
# own orders. `timeout` is how long the job may run (up to 24h).
mode: async
timeout: 5m
invoke: "user != nil"
rate_limit: { per: user, limit: 5, window: 1h }examples/config/workshop/main/_functions/export_orders/index.ts
// export_orders: build a CSV of the caller's orders and store it as a
// `reports` document; the job's result is the report's id.
//
// A job is delivered at least once: if a worker dies mid-run, another runs
// the job again. So the function must be safe to run twice. The request id
// of the call that queued the job is the same on every attempt, so it makes
// a stable key: the second attempt finds the report the first one wrote and
// returns it instead of writing another.
import { toCsv } from "../lib/csv.ts";
import type { Context, Doc } from "../lib/types.ts";
export default async function handler(ctx: Context) {
const key = ctx.idempotencyKey ?? ctx.requestId;
const db = ctx.db("main");
const reports = db.collection("reports");
const existing = await reports.list({ where: { export_key: key }, limit: 1 });
if (existing.items.length > 0) {
const report = existing.items[0];
return { report_id: report.id, rows: report.rows as number, reused: true };
}
ctx.step("read the orders");
const orders: Doc[] = [];
for await (const order of db.collection("orders").iterate({ orderBy: "_meta.created_at" })) {
orders.push(order);
}
ctx.step("write the report", { total: orders.length });
const report = await reports.create({
kind: "orders",
export_key: key,
rows: orders.length,
csv: toCsv(orders, ["id", "item", "quantity", "amount", "status"]),
requested_by: ctx.user!.email,
});
ctx.progress(orders.length, "report stored");
console.log(`exported ${orders.length} orders for ${ctx.user!.email}`);
return { report_id: report.id, rows: orders.length, reused: false };
}examples/config/workshop/main/_functions/lib/csv.ts
// Turns rows into CSV text: one header line, one line per row, fields
// quoted when they contain a comma, a quote or a line break.
export function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
const field = (value: unknown): string => {
const text = value === null || value === undefined ? "" : String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
};
const lines = [columns.join(",")];
for (const row of rows) lines.push(columns.map((c) => field(row[c])).join(","));
return lines.join("\n") + "\n";
}JOB=$(curl -s $API/main/_func/export_orders -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}' | jq -r .id)
until [ "$(curl -s $API/main/_jobs/$JOB -H "Authorization: Bearer $TOKEN" | jq -r .status)" = done ]; do sleep 1; done
curl -s $API/main/_jobs/$JOB -H "Authorization: Bearer $TOKEN" | jq .result.output
# → {"report_id": "d3c9lpr8hc2g00b6s1n0", "rows": 1, "reused": false}
curl -s $API/main/reports/d3c9lpr8hc2g00b6s1n0 -H "Authorization: Bearer $TOKEN" | jq -r .csv
More on knowing when a job finished and designing jobs that are safe to repeat.
A nightly cleanup
nightly_cleanup runs at 03:00 UTC and deletes draft orders nobody finished in 30 days, in batches of a hundred, using ctx.admin.db (a scheduled run has no caller). It is internal: nobody can call it over HTTP. It only deletes what is already past the cutoff, so running twice is harmless.
examples/config/workshop/main/_functions/nightly_cleanup/function.yaml
# A scheduled function: `schedule` (cron, UTC) on an async function. There is
# no caller, so ctx.input is empty, ctx.user is null, and ctx.admin.db (full
# access, recorded as this function) is always available in a scheduled run.
# Needs at least one running worker: `backd worker`, or
# `backd serve --with-worker`.
#
# `internal: true`: nobody can call it over HTTP. An administrator can still
# run it by hand to test it, as its schedule would run it:
# backd functions invoke --function workshop/main/nightly_cleanup
internal: true
mode: async
schedule: "0 3 * * *" # every day at 03:00 UTC
timeout: 10mexamples/config/workshop/main/_functions/nightly_cleanup/index.ts
// nightly_cleanup: delete draft orders nobody finished within 30 days.
//
// It only ever deletes drafts that are already past the cutoff, so running
// it twice (a lost worker's job is picked up again) is harmless.
import type { Context } from "../lib/types.ts";
const DAYS = 30;
export default async function handler(ctx: Context) {
const orders = ctx.admin.db("main").collection("orders");
const cutoff = new Date(Date.now() - DAYS * 86_400_000).toISOString();
let deleted = 0;
for (;;) {
const page = await orders.list({ where: { status: "draft", "_meta.created_at": { $lt: cutoff } }, limit: 100 });
if (page.items.length === 0) break;
await ctx.admin.db("main").batch(page.items.map((o) => ({ op: "delete" as const, collection: "orders", id: o.id })));
deleted += page.items.length;
}
console.log(`deleted ${deleted} draft orders older than ${DAYS} days`);
return { deleted, cutoff };
}backd functions jobs --function workshop/main/nightly_cleanup --scheduled --since 7d
More in Scheduled functions.
A daily digest on a schedule
daily_digest writes one digests document per UTC day (a unique index on day backs it) summarizing the day before, and can be called by hand to (re)build any day.
examples/config/workshop/main/_functions/daily_digest/function.yaml
# Runs once a day, at 00:00 UTC (@daily), and summarizes the day before.
#
# It can also be called by hand to (re)build one day, for example after an
# outage: an API key calls it with {"day": "2026-09-28"}. A scheduled run has
# no caller and always gets ctx.admin; a call made by hand only does with
# `admin: true`, so it is set here.
mode: async
schedule: "@daily"
admin: trueexamples/config/workshop/main/_functions/daily_digest/index.ts
// daily_digest: one `digests` document per UTC day with that day's order
// count and paid revenue. A scheduled run summarizes yesterday; a call made
// by hand can name any day ({"day": "2026-09-28"}). The day is the key (a
// unique index backs it), so running it again for the same day writes
// nothing new.
//
// To send the digest by email instead, keep the aggregation and replace the
// last create with a fetch to your mail provider's API (declare its host
// under `network:` and its key under `secrets:`; see the docs, Functions ->
// Secrets and Outbound network).
import type { Context } from "../lib/types.ts";
export default async function handler(ctx: Context) {
const db = ctx.admin.db("main");
// A scheduled run has no input: default to yesterday, in UTC.
const requested = (ctx.input as { day?: string } | null)?.day;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const day = requested ?? new Date(today.getTime() - 86_400_000).toISOString().slice(0, 10);
const start = new Date(`${day}T00:00:00Z`);
const end = new Date(start.getTime() + 86_400_000);
const digests = db.collection("digests");
if ((await digests.list({ where: { day }, limit: 1 })).items.length > 0) {
return { day, skipped: true };
}
let orders = 0;
let revenue = 0;
const created = { "_meta.created_at": { $gte: start.toISOString(), $lt: end.toISOString() } };
for await (const order of db.collection("orders").iterate({ where: created })) {
orders++;
if (order.status === "paid") revenue += order.amount as number;
}
const digest = await digests.create({ day, orders, revenue });
return { day, orders, revenue, digest_id: digest.id, skipped: false };
}To email the digest instead of storing it, see Calling an outside API.
A payment webhook
payment_webhook is called by a payment provider, not a user. It verifies the signature of the raw body, ignores an event it already handled (the provider retries), 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" };
}Set its secret with backd secret set --realm workshop --database main --name PAYMENT_WEBHOOK_SECRET, and see Webhooks for a curl that plays the provider.
Deliver email with Postmark
backd never sends mail itself: the realm names a delivery function that hands each finished message to your provider. postmark is that function for Postmark, about forty lines, and any HTTP-API provider is one small function like it. It lives in the workshop realm’s notifications database next to email-capture (which only stores messages, for development) and is tested without the network.
examples/config/workshop/notifications/_functions/postmark/function.yaml
# A delivery function for Postmark (https://postmarkapp.com): the realm's
# `email.function` can point at it instead of email-capture, and every email
# backd renders is sent through Postmark's HTTP API. Any HTTP-API provider is
# one small function like this one (docs: Functions -> Cookbook -> Deliver
# email with Postmark).
#
# It is not wired in this realm (the local stack uses email-capture, which
# stores messages instead of sending them). To use it, in realm.yaml:
# email:
# function: notifications/postmark
# from: "Acme <no-reply@acme.example>" # a verified sender signature or domain at Postmark
# and set its two secrets (`backd secret set POSTMARK_TOKEN --database notifications`).
internal: true
mode: async
# Nothing waits for an email, so a failing provider never breaks a request: a
# failed attempt is tried again with a growing wait. A message Postmark refuses
# on its merits (a bad address) is permanent and is never retried.
retry: { attempts: 5, backoff: 30s, max_backoff: 10m }
timeout: 1m
# The Server API token, and the sender Postmark has verified (a sender
# signature or a domain): both are values of this database's secrets, never
# in the config or the logs.
secrets: [POSTMARK_TOKEN, POSTMARK_FROM]
# The only host it may reach: `backd` blocks every other, so a mistake in this
# function can't send data anywhere else.
network: [api.postmarkapp.com]examples/config/workshop/notifications/_functions/postmark/index.ts
// postmark: delivers each email backd renders through Postmark's HTTP API.
//
// ctx.input is the finished message (see the docs, Functions -> Email -> The
// delivery function). The function returns Postmark's MessageID, which backd
// keeps on the email's job, and ends the way backd expects:
// - Postmark refuses the message itself (a malformed or inactive address, a
// sender it doesn't know): a 4xx FunctionError, so the email is not retried;
// - anything else that goes wrong (Postmark down, rate limited, the network,
// a wrong token): a plain error, so backd retries it per `retry`.
type Message = {
id: string;
kind: string;
from: string;
reply_to: string | null;
to: { email: string; name: string | null }[];
cc: { email: string; name: string | null }[];
bcc: { email: string; name: string | null }[];
subject: string;
text: string;
html: string;
};
type Context = {
input: unknown;
secrets: Record<string, string>;
error(status: number, code: string, message: string): Error;
};
const API = "https://api.postmarkapp.com/email";
// "Ana <ana@example.com>", or the bare address.
const address = (a: { email: string; name: string | null }) => (a.name ? `${a.name} <${a.email}>` : a.email);
export default async function handler(ctx: Context) {
const m = ctx.input as Message;
const res = await fetch(API, {
method: "POST",
headers: {
"X-Postmark-Server-Token": ctx.secrets.POSTMARK_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
// The sender Postmark verified; email.from in realm.yaml should match it.
From: ctx.secrets.POSTMARK_FROM,
To: m.to.map(address).join(","),
Cc: m.cc.length ? m.cc.map(address).join(",") : undefined,
Bcc: m.bcc.length ? m.bcc.map(address).join(",") : undefined,
ReplyTo: m.reply_to ?? undefined,
Subject: m.subject,
TextBody: m.text,
HtmlBody: m.html,
// For Postmark's own reports: the kind of email, and which backd email it was.
Tag: m.kind,
Metadata: { backd_email_id: m.id },
MessageStream: "outbound",
}),
signal: AbortSignal.timeout(30_000),
});
const body = await res.json().catch(() => ({})) as { MessageID?: string; ErrorCode?: number; Message?: string };
// 422: Postmark understood the request and refuses the message (ErrorCode 300
// invalid email, 406 inactive recipient, 400 unknown sender, …). Retrying
// can't change that.
if (res.status === 422) {
throw ctx.error(422, `postmark_${body.ErrorCode ?? "rejected"}`, body.Message ?? "Postmark rejected the message");
}
// Anything else that isn't a success is worth another attempt (or, for a
// wrong token, a visible failure in the job list until it is fixed).
if (!res.ok) throw new Error(`Postmark answered ${res.status}: ${body.Message ?? "no message"}`);
console.log(`sent ${m.kind} email ${m.id} through Postmark`);
return { message_id: body.MessageID };
}Step by step
At Postmark: create a Server and copy its Server API token. Add a Sender Signature (a single address) or, better, verify your domain (SPF and DKIM), and use an address of it as the sender. Postmark refuses mail from anything it hasn’t verified.
Copy the function into your realm’s database (here
notifications/_functions/postmark/, with adeno.jsonlike the one next to it) and runbackd functions build.Set its two secrets, the token and the sender (
POSTMARK_FROMis the verified sender, such asAcme <no-reply@acme.example>; keepemail.frominrealm.yamlthe same):backd secret set --realm acme --database notifications --name POSTMARK_TOKEN --url https://api.example.com backd secret set --realm acme --database notifications --name POSTMARK_FROM --url https://api.example.comPoint the realm at it in
realm.yaml, and remove the development function from the deployment (dev_onlymakesbackdrefuse to start with it outside development):email: function: notifications/postmark from: "Acme <no-reply@acme.example>" public_url: https://api.acme.exampleTry it without sending anything. Postmark’s special token
POSTMARK_API_TESTmakes the API validate a request and answer as if it had sent it, with nothing delivered: set it asPOSTMARK_TOKEN, sign someone up, and read the email job’s result in the job list. Then set the real token.Test the function itself with fake responses, as
index.test.tsdoes (make functions-testing-test): the request Postmark receives, a refusal, and every kind of failure.
What backd does for you, and what it leaves to you
- Retries. A failed attempt (Postmark down, rate limited, the network, a wrong token) is a plain error, so
backdretries it asretrysays, and the job list shows each attempt. A message Postmark refuses on its merits (422: a malformed or inactive address, an unknown sender) is a4xxerror from the function, which is permanent: no retry. - Secrets and links. The token is a secret, never in the config or the logs. Links and tokens in the message are masked in the function’s logs, and the function can only reach
api.postmarkapp.com. - Duplicates. Postmark has no idempotency key, so an attempt whose answer was lost and is retried can send the message twice. The function sends the email’s id as metadata (
backd_email_id), so a repeat is visible in Postmark’s reports. Providers with a key (Resend’sIdempotency-Key) can usectx.input.id, which is the same on every retry, to drop it. - Deliverability is yours: domain authentication, bounces, complaints and suppression lists are Postmark’s dashboard and your sending reputation, not
backd’s (see Email).
Another provider
The shape never changes: build the request from ctx.input, send it with fetch, return { message_id }, throw ctx.error(4xx, …) when the provider refuses the message and a plain error otherwise, declare the key as a secret and the host in network. What differs (check the provider’s current documentation for the details):
| Provider | Endpoint | Authentication | Notes |
|---|---|---|---|
| Resend | POST https://api.resend.com/emails | Authorization: Bearer <key> | Send Idempotency-Key: <ctx.input.id>: a retry can’t duplicate |
| SendGrid | POST https://api.sendgrid.com/v3/mail/send | Authorization: Bearer <key> | A 202 with an empty body is the success; the message id is in a header |
| Mailgun | POST https://api.mailgun.net/v3/<domain>/messages | HTTP basic: api:<key> | Form-encoded, not JSON; EU accounts use api.eu.mailgun.net |
| Brevo | POST https://api.brevo.com/v3/smtp/email | api-key: <key> header | |
| Amazon SES | POST https://email.<region>.amazonaws.com/v2/email/outbound-emails | AWS Signature V4 | Needs request signing: more code, same contract |
What to take from these
- Run as the caller by default (
ctx.db); useadmin: truedeliberately, for changes a rule must not allow, and keep those functions small. - Make every function that can run twice safe to run twice: a key derived from the job, a unique index, an idempotency key.
- Let the data say it’s done: a report document, a status field, an event: clients look where they already look.
- Test the logic with Deno and the wiring for real: the workshop has both.
- An email provider is one small function: the contract is a finished message in, a message id out, a
4xxerror for what the provider refuses and a plain error for what is worth retrying.