FAQ

Questions a careful engineer asks

Honest answers about the guarantees — and the explicit non-goals. CommitCourier is deliberately scoped; knowing the edges is part of trusting it.

Is delivery exactly-once?

No — it's at-least-once. FOR UPDATE SKIP LOCKED means two dispatchers never claim the same row at once, but that's a claim guarantee, not a delivery count: a crash after a successful HTTP send but before the status commit causes one redelivery once the visibility-timeout reclaim fires. The dual-write guarantee (no phantom / lost webhooks) is exact; the network delivery is at-least-once, like every honest webhook system.

Then how do I get exactly-once effects?

Dedup on the receiver. Every delivery carries a stable webhook-id (and your optional idempotency-key); record it and ignore repeats. The id is the outbox row's own id, so it survives every retry and the crash-redelivery above — which is what makes at-least-once workable rather than merely honest. One exception worth knowing: relay.replay() re-enqueues as new rows with new ids, so a replayed event is deliberately not deduped by webhook-id. The live demo shows the self-receiver doing exactly this.

Are events ordered?

No, not by default — the dispatcher claims a batch oldest-first and then delivers it concurrently, so deliveries race and can land out of order. Per-endpoint FIFO is opt-in via createDispatcher({ ordering: "per-endpoint" }), with one catch that matters: it only serialises registered endpoints. If you enqueue with an inline endpoint: { url, secret } — the quick path shown in most of these examples, and what this demo itself uses — opting in changes nothing. Register the endpoint first (see targeting endpoints by id), then opt in. For strict global ordering across an endpoint, design for it explicitly.

I run serverless — no long-lived process. Can I still use it?

Yes. The background dispatcher is one option; on serverless, drive delivery from a scheduled function instead:
// Serverless / no long-lived process? Skip the dispatcher loop and
// drain a batch from a scheduled function (cron, Lambda, Cloud Run job):
await relay.dispatchOnce({ max: 50 });

Isn't a ~1s polling loop too slow?

pollIntervalMs (default 1000) is a ceiling, not a tick. The idle sleep starts at ~50ms and only doubles toward the ceiling while the queue stays empty, resetting the moment a row is found — so a backlog drains at roughly 50ms per pass, and the full second is what you pay on an idle queue. When you want the first delivery not to wait for a poll at all, wire the optional commitcourier/accelerator/pg accelerator: a transactional pg_notify on COMMIT wakes a listening dispatcher at once via Postgres LISTEN/NOTIFY. It's best-effort — a missed wake only falls back to polling, never drops the row. (This site doesn't wire it: a demo is better served by showing the honest default.)
import { Client } from "pg";
import { createPgAccelerator } from "commitcourier/accelerator/pg";

// Wake the dispatcher the instant an enqueue commits, via Postgres LISTEN/NOTIFY:
const accelerator = createPgAccelerator({
  pool,                                        // fires a transactional NOTIFY on COMMIT
  listen: async () => { const c = new Client(cfg); await c.connect(); return c; },
});
const relay = await createRelay({ store, accelerator });
// Best-effort: a missed NOTIFY only delays delivery — the poller still reclaims the row.

Does it scale? Can I run multiple dispatchers?

Run as many as you like — FOR UPDATE SKIP LOCKED stops two dispatchers claiming the same row. It targets small-to-medium volume on the Postgres you already operate, not billions/sec. No Redis, no broker, no SaaS.

Do I have to inline the URL and secret on every enqueue?

No — inline endpoint: { url, secret } is the quick path, but you can register endpoints once and target them by id. The registry also gives you zero-downtime key rotation: rotateSecret dual-signs with the old and new keys until every receiver has migrated, then finalizeRotation drops the old one.
// Register endpoints once, then target them by id instead of inlining { url, secret }:
const { id } = await relay.endpoints.register({
  url: "https://customer.example.com/webhooks",
  secret,
});
await relay.enqueue(trx, { eventType, payload, endpoint: { endpointId: id } });

// Rotate the signing key with zero downtime — deliveries are dual-signed with both keys:
await relay.endpoints.rotateSecret(id, newSecret);
await relay.endpoints.finalizeRotation(id); // once receivers have migrated to newSecret

I already use Svix / a webhook SaaS — can CommitCourier feed it instead of sending HTTP itself?

Yes. Set delivery.transport: "sink" and pass a Sink — the official svixSink sample, or your own — and CommitCourier keeps the transactional outbox, retries, DLQ and ledger while handing each event off to your SaaS instead of delivering the HTTP request itself. Signing and SSRF are delegated to the SaaS, and your idempotency-key maps onto its dedup key. This is how you "keep your platform, close the transactional gap." Experimental — this API may change in a minor release.
import { Svix } from "svix";
import { svixSink } from "commitcourier/forward/svix";

// Experimental: keep the transactional outbox, but hand delivery off to your SaaS
// instead of CommitCourier sending the HTTP request itself.
const relay = await createRelay({
  store,
  delivery: { transport: "sink" },
  sink: svixSink({ svix: new Svix(process.env.SVIX_TOKEN), appId }),
});
// enqueue still rides your business TX; the dispatcher forwards each event to Svix,
// and your idempotencyKey maps onto Svix's dedup key.

Isn't this the same pitch as Postel?

Largely, yes — and it's the comparison worth making, so here it is unprompted. Postel is an embedded library with a near-identical claim: transactional outbox, signing, retries, dead-letter, replay, no Redis and no broker. We aren't going to pretend it doesn't exist.
It casts wider than we do. It reaches beyond Postgres to SQLite, it does inbound webhook receiving and not just sending, it signs with Ed25519 and publishes JWKS where CommitCourier is HMAC-SHA256 only, and it has a polyglot roadmap while we are Node and nothing else. If you need any of those, use it — that's not a hedge.
CommitCourier trades that reach for depth on one database: SSRF protection on by default, at-rest secret encryption, an endpoint circuit breaker, OpenTelemetry, the LISTEN/NOTIFY accelerator, pg / Knex / Drizzle / Prisma adapters, a doctor CLI, DLQ inspection and replay, and the sink handoff to a SaaS. On maturity neither of us should be oversold: Postel calls itself pre-alpha, we're 0.x and a minor can still break you.

How are signing secrets protected at rest?

That's your precondition: DB disk encryption, column encryption, or a cipher: createAesGcmCipher(key). Without one, createRelay warns at startup and you acknowledge with unsafeAllowPlaintextSecrets: true (this demo does, for visibility — don't in production).
When NOT to reach for it
  • Ultra-low-latency fan-out — the LISTEN/NOTIFY accelerator trims the poll delay, but this isn't a sub-millisecond streaming bus.
  • Hyperscale fan-out (millions/sec) — use a dedicated streaming/queue platform.
  • Stacks with no PostgreSQL transaction to ride (the guarantee needs your business TX).