Background Tasks
Kide uses a transactional-outbox queue (cms_outbox) for work that must reliably happen outside a request, such as API syncs, email, or cleanup. Enqueuing happens in the content database; a worker claims due rows and runs handlers.
Handlers
Section titled “Handlers”Register handlers in cms.config.ts under integrations.tasks, keyed by task type:
export default defineConfig({ integrations: { tasks: { "sync.listings": (payload) => syncListings(payload), // Handlers that use the generated cms API must be registered with a // dynamic import — a static import creates a module cycle // (cms.config → handler → generated api → cms.config). "alerts.notify": () => import("@/lib/alerts").then((mod) => mod.notify()), }, },});The handler contract:
- Throw to retry. Failed tasks back off exponentially (
30s × 2^(n-1)) and are markedfailedaftermaxAttempts(default 5). - Return to complete.
- Delivery is at-least-once. A crashed process’s claim lease (5 minutes) expires and the task runs again — handlers must tolerate re-runs.
- Handlers receive
(payload, { config }).
Enqueuing
Section titled “Enqueuing”import { cms } from "@/cms/.generated/api";
await cms.tasks.enqueue("sync.listings", { kind: "job", locale: "fi" });await cms.tasks.enqueue("cleanup.exports", null, { delayMs: 60_000, // run no earlier than one minute from now maxAttempts: 3, dedupeKey: "cleanup.exports", // skipped if a pending task has the same key});cms.tasks also exposes drain(), tick() (evaluate schedules), and prune() (delete done
and failed rows older than 7 days) for manual control.
Recurring schedules
Section titled “Recurring schedules”Declare recurring work under integrations.schedules — no extra table, no cron syntax:
integrations: { schedules: [ { task: "sync.listings", payload: { kind: "job", locale: "fi" }, everyMinutes: 10 }, { task: "alerts.notify", everyMinutes: 15 }, ],},Each cron tick enqueues a schedule’s task if it isn’t already pending and its interval has elapsed. Payload identity is part of the dedupe key, so the same task can be scheduled with several payloads.
Triggering
Section titled “Triggering”GET/POST /api/cms/cron/tasks runs tick → drain → prune. The prune step also clears expired rate-limit rows and audit-log entries older than 90 days. If CRON_SECRET is set, the endpoint requires Authorization: Bearer ${CRON_SECRET} — in development too. If it’s unset, the endpoint is open in development and returns 401 in a production build. Trigger it with:
- an external cron service or systemd timer;
- a
setIntervalin a long-lived Node server process; - on Cloudflare, the synthesized Worker
scheduled()handler already calls it.
Inbound webhooks
Section titled “Inbound webhooks”POST /api/cms/webhooks/[provider] verifies the raw body with HMAC-SHA256 using WEBHOOK_SECRET_<PROVIDER> (uppercased provider name; hyphens become underscores). The sender must set an x-webhook-signature header containing a hex-encoded HMAC-SHA256 of the body; an optional sha256= prefix is accepted. On a valid signature it enqueues webhook.<provider>:
integrations: { tasks: { "webhook.github": (payload) => handlePush(payload), },},Unknown providers (no secret configured) return 404. There is no inbound idempotency store — providers may redeliver, so handlers must tolerate duplicates.
For notifying external services about content changes, see Webhooks — that outbound path is separate from this queue.
Where provider code belongs
Section titled “Where provider code belongs”Keep provider-specific code (API clients, sync logic, read models) in your app (src/lib/),
not in src/cms/core/. The CMS layer stays provider-neutral: it gives you the queue, the
schedule evaluator, and the verified webhook inbox. What runs inside a handler is yours.