Skip to content

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.

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 marked failed after maxAttempts (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 }).
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.

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.

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 setInterval in a long-lived Node server process;
  • on Cloudflare, the synthesized Worker scheduled() handler already calls it.

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.

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.