commitcourier
v0.4.0
Published
Transactional Outbound Webhook delivery for Node.js/TypeScript, backed by your existing PostgreSQL.
Maintainers
Readme
CommitCourier
Transactional Outbound Webhook delivery for Node.js / TypeScript, backed by your existing PostgreSQL.
🇯🇵 日本語版: README.ja.md · 🔒 Security policy
CommitCourier bolts reliable outbound webhooks onto an existing Node.js / TypeScript app — framework-agnostic, with no extra infrastructure (just the Postgres you already run). You enqueue a webhook inside your own business transaction, so it commits or rolls back atomically with your business write. The background dispatcher then delivers it with Standard Webhooks signing, retries, a DLQ, a full delivery ledger, SSRF protection, and single-delivery across instances.
⚠️ Pre-release. The API and the package name may still change before
1.0.0.
Table of contents
- Why
- Quick start
- Use cases
- Comparison
- Features
- How it works
- Configuration
- Operations
- CLI:
commitcourier doctor - Error handling
- Verifying signatures (receiver side)
- Guarantees & non-goals
- Migrations
- Removing CommitCourier
- API surface
- Feature status
- Compatibility & support
- Roadmap
- Security
- License
Why
Updating business state and sending a webhook are two separate actions. If a crash or rollback lands between them, you get a dual-write bug:
- Phantom webhook — you enqueue the webhook first, then the business transaction rolls back. A customer receives
order.createdfor an order that never existed. - Lost webhook — you commit the business transaction first, then the process dies before enqueuing. The order is final, but the notification never fires.
Most of the usual tools don't address this structurally: SaaS senders (Svix, Outpost) and Redis-backed queues (BullMQ) enqueue to a remote system that can't join your local DB transaction, and broker-outbox libraries ride your transaction but only deliver to a message broker — no HTTP webhook delivery, no signing, no SSRF guard, no delivery ledger.
CommitCourier is an embedded library that rides your own DB transaction and carries it all the way to webhook-grade HTTP delivery. Because the outbox row is written in the same transaction as your business change, dual-write inconsistency is impossible by construction.
Quick start
Install the package plus the driver you use:
npm install commitcourier
# plus the driver you use (optional peer dependency):
npm install pg # or: npm install knexRequirements: Node.js 22.19.0+, PostgreSQL 12+ (the minimum supported version; the DDL uses GENERATED ALWAYS AS IDENTITY and FOR UPDATE SKIP LOCKED). CI integration tests run against PostgreSQL 12, 16, and 17. Ships dual ESM/CJS builds with bundled TypeScript types. pg and knex are optional peer dependencies — install whichever one you use.
1. Create the tables
migrate() applies idempotent DDL — three business tables (webhook_outbox, webhook_delivery_attempts, webhook_endpoints) plus a commitcourier_migrations tracking table. Run it once at deploy time (see Migrations).
import { Pool } from "pg";
import { postgresStore } from "commitcourier/store/pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const store = postgresStore({ pool });
await store.migrate();2. Create the relay
createRelay is async: it validates config and fails fast if the tables are missing.
import { createRelay, createConsoleLogger } from "commitcourier";
const relay = await createRelay({
store,
// Inject a logger from the start: the default is a no-op, so without one routine delivery
// failures and retries are silent. See [Logging & observability](#logging--observability).
logger: createConsoleLogger(),
// all of the following are optional and shown with their defaults:
mode: "active",
signing: { scheme: "standard-webhooks" },
retry: { maxAttempts: 12, backoff: "exponential", baseMs: 1_000, capMs: 3_600_000, jitter: 0.2 },
delivery: { timeoutMs: 15_000, bodySnippetBytes: 4_096 },
ssrf: { blockPrivateRanges: true, allowlist: [], blocklist: [] },
// maxPayloadBytes: 1_048_576, // optional, off by default: cap the enqueued payload size
});3. Enqueue inside your business transaction
enqueue takes the transaction handle as its required first argument. With pg that handle is the PoolClient running your BEGIN/COMMIT. If the transaction rolls back, the outbox row disappears with it.
const client = await pool.connect();
try {
await client.query("BEGIN");
// ... your business writes on `client` ...
await client.query("INSERT INTO orders (id, amount) VALUES ($1, $2)", [orderId, amount]);
// Rides the same transaction (fail-closed):
await relay.enqueue(client, {
eventType: "order.created",
payload: { orderId, amount },
endpoint: { url: "https://customer.example.com/webhooks", secret: "whsec_..." },
idempotencyKey: orderId, // optional
});
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK"); // the outbox row rolls back too
throw err;
} finally {
client.release();
}No business transaction?
relay.enqueueUnsafe(input)enqueues on its own connection — but you lose the atomicity guarantee, which is the whole point. Use it only where there is genuinely no surrounding transaction.
Inline vs registered endpoints. The examples pass the destination inline as
endpoint: { url, secret }, which is the primary path: the secret is snapshotted onto the outbox row at enqueue time. You can instead reference a row inwebhook_endpointswithendpoint: { endpointId }, and manage those rows through therelay.endpointsadmin API —register({ url, secret, … }),update,enable,disable, andget.
4. Run the dispatcher
The dispatcher polls for due rows and delivers them in the background. Run it in your app process or a dedicated worker — running several copies is safe.
const dispatcher = relay.createDispatcher({
concurrency: 8,
pollIntervalMs: 1_000,
reclaimAfterMs: 300_000,
});
await dispatcher.start();Wire a graceful shutdown so an in-flight delivery is not lost when the process is asked to stop — see Graceful shutdown.
Using Knex instead of pg
import { knexStore } from "commitcourier/store/knex";
const store = knexStore({ knex });
await store.migrate();
const relay = await createRelay({ store });
await knex.transaction(async (trx) => {
// ... your business writes on `trx` ...
await relay.enqueue(trx, {
eventType: "order.created",
payload: { orderId, amount },
endpoint: { url: "https://customer.example.com/webhooks", secret: "whsec_..." },
});
});Prefer a runnable file? See
examples/basic-pgfor the full migrate → enqueue → dispatch flow against a throwaway Postgres.
Use cases
- Customer-facing webhooks (e-commerce / SaaS). Emit
order.created,payment.succeeded, orsubscription.updatedto your customers' endpoints atomically with the DB write that produced them — no phantom or lost notifications. - Internal service-to-service events. Fan domain events out between your own services on the Postgres you already run, without standing up Kafka, Redis, or a message broker.
- Replacing a hand-rolled "send after commit". Migrate off ad-hoc
fetch(...)-after-commit code that silently drops events on a crash, or re-sends them after a rollback. - Serverless / cron delivery. Drain the queue from an AWS Lambda or a scheduled task with
relay.dispatchOnce()instead of running a long-lived worker.
Comparison
How CommitCourier differs from the usual ways to send outbound webhooks:
| | Rides your DB transaction | HTTP webhook delivery | Signing | Extra infrastructure | | ----------------------- | :-----------------------: | :----------------------: | :------------------: | ----------------------------- | | CommitCourier | ✅ | ✅ | ✅ Standard Webhooks | None (your existing Postgres) | | Svix / Outpost (SaaS) | ❌ | ✅ | ✅ | Hosted SaaS / server | | BullMQ & similar queues | ❌ | Do-it-yourself (handler) | Do-it-yourself | Redis | | Broker-outbox libraries | ✅ | ❌ (message broker) | ❌ | A message broker |
CommitCourier combines both halves: the outbox row is written inside your transaction (so dual-write inconsistency is impossible) and carried all the way to webhook-grade HTTP delivery (signing, retries, DLQ, ledger, SSRF). SaaS and Redis-backed senders can't join your local transaction; broker-outbox libraries ride it but stop at a message broker.
It isn't the only embedded library in this space — Postel takes a comparable transactional-outbox approach and casts wider in some directions (a polyglot roadmap, SQLite, and inbound webhook receiving). CommitCourier's focus is depth on Postgres: SSRF protection, at-rest secret encryption, an endpoint circuit breaker, OpenTelemetry, a LISTEN/NOTIFY low-latency accelerator, pg / Knex / Drizzle / Prisma adapters, a doctor CLI, a read-only DLQ inspection + replay API, and an optional handoff to a delivery SaaS via the sink transport.
Features
- Transactional
enqueue— rides your DB transaction; the webhook is atomic with your business write (fail-closed). - Postgres-only — no Redis, no separate broker, no extra server.
- Standard Webhooks signing — receivers verify with any off-the-shelf Standard Webhooks library, or with the bundled dependency-free
verifySignaturehelper fromcommitcourier/core. - Retries with exponential backoff + jitter, and a DLQ for exhausted rows.
- Delivery ledger — every attempt's request headers, response status, body snippet, and duration are recorded for support and audit.
- Replay — re-enqueue by id or by filter (e.g. all
deadrows since a time), with a built-in safety cap so a broad replay never fans out into an unbounded mass re-send. - Cancel — stop a not-yet-sent row before it leaves (
relay.cancel(id)); already-sent / in-flight rows are untouched. - Serverless / cron friendly —
relay.dispatchOnce()drains the queue once and returns, so you can deliver from a Lambda or cron tick without a long-lived process. - Endpoint circuit breaker — optionally auto-disable a registered endpoint after N consecutive failures, so a permanently-down destination stops being hammered with HTTP retries (and, with
cooldownMs, recovers on its own via a half-open probe). - Built-in retention —
relay.prune({ olderThan })deletes old terminal rows in bounded batches (active rows are never touched), so tables don't grow forever. - SSRF protection on by default — common private, loopback, link-local, cloud-metadata, and other non-public network targets (shared/CGNAT, multicast, broadcast, reserved/documentation ranges) are blocked, against both the parsed URL host and every DNS-resolved IP, with the vetted IP pinned at connect time.
- Single delivery across instances via
FOR UPDATE SKIP LOCKED; at-least-once via visibility-timeout reclaim. - Observe mode — record what would be sent without sending, for safe phased rollout.
- Built-in at-rest encryption for signing secrets — plug in
cipher(a built-in WebCrypto AES-256-GCM helper, or your own KMS/Vault adapter) to keepsecret_snapshot/ endpoint secrets as ciphertext in the DB. At-rest encryption is a precondition (this, DB disk encryption, or column encryption); skipping it triggers a startup warning.
How it works
┌──────────────────────────────────────────────────────────┐
│ Your application │
│ │
│ business logic ── db.tx ──┐ │
│ ▼ │
│ relay.enqueue(trx, …) │ ← INSERT outbox row in the same tx
└─────────────────────────┬────────────────────────────────┘
│ commit / rollback (atomic)
▼
┌─────────────────────────────────┐
│ PostgreSQL (your existing DB) │ ← source of truth
│ webhook_outbox │
│ webhook_delivery_attempts │
│ webhook_endpoints (optional) │
└───────────────┬─────────────────┘
│ poll (claim rows with SKIP LOCKED)
▼
┌──────────────────────────────────────────────┐
│ Dispatcher (background loop) │ ← fail-open
│ ├ Claim: lock due rows exclusively │
│ ├ SSRF Guard: validate destination URL │
│ ├ Signer: Standard Webhooks signature │
│ ├ HTTP: POST with timeout │
│ └ Ledger: record attempt → transition │
│ (delivered / retry / dead) │
└──────────────────────────────────────────────┘
│
▼
external endpointThe two paths are deliberately separate:
enqueueis fail-closed — it rides your transaction. If the outbox row can't be written, your business transaction doesn't commit either. (In practice this is just a cheap localINSERT, far more reliable than a remote call.)dispatchis fail-open — a delivery or DB error during dispatch never propagates to your business path. It's logged, recorded in the ledger, and left to retry / DLQ.
A row's lifecycle:
pending ──claim──▶ in_flight ──2xx──▶ delivered
▲ │
│ fail & attempts<max (available_at = now + backoff)
└───────────────────┤
│ fail & attempts>=max
▼
dead (DLQ)
enqueue in observe mode ─▶ observed (recorded, never sent)
manual cancel ─▶ cancelledIf a worker dies mid-delivery, its row stays in_flight until locked_at exceeds the visibility timeout (reclaimAfterMs, default 5 min); the next tick reclaims it back to pending. That's how CommitCourier guarantees at-least-once.
Crash-looping ("poison") rows.
attemptsis incremented only when a delivery attempt completes and records its outcome — a process crash before that (OOM,SIGKILL, etc.) leavesattemptsunchanged, so a reclaim returns the row topendingwithout consuming the retry budget. This is deliberate: a transient infrastructure failure must not burn attempts or drop the event. The trade-off is that a row which crashes the worker on every delivery is reclaimed and retried indefinitely (everyreclaimAfterMs) rather than being dead-lettered. Ordinary delivery failures (HTTP errors, timeouts, exceptions inside delivery) always record an attempt and do count towardmaxAttempts; only a hard process crash mid-delivery is exempt. If you need to stop such a row, watch for repeated reclaims (e.g. an oldcreated_atstillpending/in_flight) andrelay.cancel(id)it.
Retry & failure classification
What happens to a delivery is a stable part of the contract:
| Outcome of an attempt | Action |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 2xx | delivered (terminal). |
| 410 Gone | Straight to dead without consuming the retry budget; a registered endpoint is also disabled. |
| Any other 4xx / 5xx | Retry with exponential backoff until retry.maxAttempts, then dead (DLQ). |
| Network error / connection reset / TLS / timeout | Same as above — retried, then dead. |
| SSRF_BLOCKED (destination resolved to a blocked IP) | Retryable failure, surfaced on every attempt; ends in dead if it never clears. |
| Missing/invalid signing secret (pre-HTTP, deterministic) | Straight to dead (the endpoint is not disabled — the row, not the endpoint, is the problem). |
A server-sent Retry-After (delta-seconds or an HTTP-date) is honoured when it exceeds the computed backoff, clamped to retry.capMs so a hostile or buggy header cannot park a row indefinitely; an unparseable value falls back to the normal backoff. Only 2xx is treated as success.
Configuration
All config is optional and merged over safe defaults. Invalid values are rejected at startup with RelayError("CONFIG_INVALID"); dangerous-but-valid ones (e.g. disabling the SSRF guard) are allowed but warned via the logger.
| Group | Option | Default | Notes |
| ---------- | -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| | mode | "active" | "observe" records rows as observed and never sends. |
| signing | scheme | "standard-webhooks" | Only Standard Webhooks is supported. |
| retry | maxAttempts | 12 | Integer ≥ 1. |
| retry | backoff | "exponential" | baseMs * 2^(attempts-1), capped. |
| retry | baseMs | 1000 | |
| retry | capMs | 3600000 | Must be ≥ baseMs. |
| retry | jitter | 0.2 | Fraction in 0..1, on by default to avoid thundering herds. |
| delivery | timeoutMs | 15000 | Per-request HTTP timeout. |
| delivery | bodySnippetBytes | 4096 | How much of the response body is stored in the ledger. |
| delivery | keepAliveTimeoutMs | 10000 | undici keep-alive window; longer reuses TCP/TLS across bursts to the same host. |
| delivery | connections | (undici default) | Optional cap on simultaneous connections per origin. |
| ssrf | blockPrivateRanges | true | Blocks private / loopback / link-local / metadata IPs. |
| ssrf | allowlist | [] | Host patterns to permit. |
| ssrf | blocklist | [] | Host patterns to deny. |
| | endpointCacheTtlMs | 0 (off) | TTL (ms) for an in-process registered-endpoint lookup cache; see Performance tuning. |
| | maxPayloadBytes | (off) | Optional cap on the enqueued payload's serialized UTF-8 byte length; over-size rejects with ENQUEUE_INVALID_PAYLOAD. Serializability is always validated. |
Dispatcher options (relay.createDispatcher({ … })):
| Option | Default | Notes |
| ---------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| concurrency | 8 | Max concurrent deliveries. |
| pollIntervalMs | 1000 | Upper bound of the idle poll wait; an idle loop backs off from ~50ms up to this, and a full batch ticks again immediately. |
| reclaimAfterMs | 300000 | Visibility timeout: reclaim in_flight rows older than this. |
| batchSize | concurrency * 2 | Rows claimed per tick. |
Performance tuning
Throughput is mostly about giving the dispatcher room to work:
- Concurrency vs. pool size. Raising
concurrencyonly helps if thepg.Poolhas connections to spare: the dispatch path uses one connection per in-flightclaimDue/completeAttempt. SizePool({ max })to at leastconcurrencyplus headroom, or deliveries stall waiting on the pool. - Batch and connections.
batchSize(defaultconcurrency * 2) caps the in-flight buffer;delivery.connectionscaps sockets per destination. Tune both to the workload, and lengthendelivery.keepAliveTimeoutMswhen you deliver many events to the same hosts. - Registered-endpoint cache. With the registered-endpoint workflow every delivery looks the endpoint up in the DB. Set
endpointCacheTtlMs(e.g.1000–5000) to cache lookups in-process;update/disableevict immediately within the process, and the TTL bounds how long another process's change can be stale. It has no effect on the inline{ url, secret }workflow. With multiple dispatcher processes,endpointCacheTtlMsis also the upper bound on how long another process keeps delivering with a stale endpoint after adisableor a key rotation — so keep it short, and when rotating a secret, leavefinalizeRotationuntil at leastttlMsafter the last delivery (until then a peer may still sign with only the previous key). - Indexes are built in. The claim and reclaim queries use partial indexes over only the
pending/in_flightrows, so they stay fast as delivered/dead rows accumulate — no tuning needed.
Phased rollout: start in mode: "observe" to record the volume and destinations of what would be sent, diff it against expectations, then switch to "active".
Signing secret format: a whsec_-prefixed secret is treated as Base64 per the Standard Webhooks convention and decoded to raw key bytes; any other string is used as raw UTF-8 bytes.
Encrypting secrets at rest (precondition): signing secrets (secret_snapshot / endpoint secret) are sensitive, so encryption at rest is a precondition — pick one of: ① database disk/volume encryption, ② column-level encryption, or ③ pass a cipher to createRelay({ store, cipher }) so the library keeps them as ciphertext. For ③ use the built-in createAesGcmCipher(key) (WebCrypto AES-256-GCM; generateSecretKey() mints a key) or your own SecretCipher over a KMS/Vault — managing the key (storage, distribution, rotation) is then your responsibility. Without a cipher, secrets are stored as-is (plaintext), so createRelay prints a startup warning; if you are using ① or ②, acknowledge and silence it with createRelay({ store, unsafeAllowPlaintextSecrets: true }).
Operations
// Delivery ledger for one outbox row (every attempt, response, duration).
const attempts = await relay.attempts({ outboxId });
// Inspect one row (read-only, secret-free), or null when unknown.
const row = await relay.get(outboxId);
// Cancel a not-yet-sent row. { cancelled: false } if it was already claimed / sent / unknown.
const { cancelled } = await relay.cancel(outboxId);
// Replay: re-enqueue as fresh pending copies. By id…
const { ids } = await relay.replay({ outboxId });
// …or by filter (e.g. everything dead for one endpoint since a timestamp). The selection is capped:
const res = await relay.replay({
filter: { status: "dead", endpointId, since: new Date(Date.now() - 86_400_000) },
});
// `res.capped === true` means the cap truncated the match set, so not everything was re-sent. To
// replay more, NARROW the filter (e.g. by `endpointId` or a tighter `since`) or raise `filter.limit` —
// do NOT loop on the same filter: replay leaves the source rows untouched (the dead rows stay dead),
// so an identical call re-selects the same head rows and would re-send them (duplicates).
// Disable a registered endpoint.
await relay.endpoints.disable(endpointId);Delivery hooks
createRelay({ hooks }) accepts onDelivered / onRetry / onDead, each called with a secret-free DeliveryEvent (id, event type, attempt number, endpoint id, host, status, error, duration — never the payload or signing secret). The contract:
- Fired only after the row's state transition actually commits. A worker that lost its lease to a visibility-timeout reclaim records its ledger attempt but fires no hook — the worker that owns the row does.
- At-least-once, not exactly-once. A retry fires
onRetryagain, and a redelivery after a crash can fireonDeliveredmore than once. Treat them as notifications keyed byid+ attempt, not as a ledger (the ledger isrelay.attempts). - Fail-open. A throwing hook is caught, logged, and swallowed; it never rolls back the delivery state or stalls the dispatcher. Keep hooks fast — they run inline on the dispatch path (offload slow work to your own queue).
Graceful shutdown
dispatcher.stop() stops new ticks, interrupts the idle wait, drains in-flight deliveries, and unsubscribes any accelerator — but binding it to your process signals (and closing the DB pool afterwards) is yours to wire. Under a container orchestrator a missed shutdown means the process is hard-killed mid-delivery; the row is still safe (the visibility timeout reclaims it), but you pay a redelivery you could have avoided. A typical long-lived worker:
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return; // a second SIGTERM/SIGINT must not race the drain
shuttingDown = true;
await dispatcher.stop(); // stop ticks + drain in-flight deliveries
await pool.end(); // close the pg pool once nothing else will query
process.exit(0);
}
for (const sig of ["SIGTERM", "SIGINT"] as const) process.on(sig, () => void shutdown());For a one-shot serverless/cron model there is no loop to stop: await relay.dispatchOnce(...) resolves only after the rows it claimed are delivered, so just await pool.end() once it returns (see below).
Serverless / cron delivery (one-shot)
When you can't host a long-lived dispatcher (AWS Lambda, a cron job, a scheduled task), drain the queue once and return instead of running the loop:
// Reclaims stale locks, claims due rows in waves (honouring concurrency/batchSize/ordering),
// delivers them, and resolves once the queue is empty (or maxRows is hit).
const { processed } = await relay.dispatchOnce({ concurrency: 8 }, { maxRows: 500 });dispatchOnce returns the number of rows dispatched this run. Pick one model per process: the running-loop guard is per dispatcher instance (dispatcher.runOnce() rejects while that same dispatcher's start() loop is active), and dispatchOnce creates a fresh dispatcher each call, so it does not detect a loop started elsewhere. Overlap is still safe — FOR UPDATE SKIP LOCKED keeps every row single-claim — it just means both compete for the same rows.
Auto-disabling failing endpoints (circuit breaker)
A permanently-down registered endpoint otherwise keeps receiving (and failing) every retry until each row exhausts its budget into the DLQ. Enable the circuit breaker to auto-disable it after N consecutive failures (a success resets the count):
const relay = await createRelay({ store, circuitBreaker: { failureThreshold: 20 } });Default failureThreshold: 0 keeps it off. It only applies to the registered-endpoint workflow (inline { url, secret } deliveries have no endpoint to disable), is fail-open, and re-enabling is a normal relay.endpoints.enable(endpointId).
What it does — and doesn't do — to the DLQ. Auto-disabling stops the endpoint from being hammered with HTTP retries: once disabled, its due rows fail fast with
ENDPOINT_DISABLED(no HTTP call, no timeout) instead of a slow request each attempt. Those rows still consume their retry budget, so if the endpoint is never re-enabled or recovered they still end up in the DLQ — the breaker cuts load and surfaces the outage, it does not by itself keep rows out of the DLQ. AddcooldownMs(below) so a recovered endpoint's rows can deliver before they exhaust their budget.
For hands-off recovery, add cooldownMs so a disabled endpoint heals on its own instead of waiting for an admin:
const relay = await createRelay({
store,
circuitBreaker: { failureThreshold: 20, cooldownMs: 5 * 60_000 },
});Once an endpoint has been disabled for at least cooldownMs, the dispatcher lets a trial delivery through as a half-open probe: a success re-activates it (and resets the counter), a failure re-arms the cooldown so the next trial waits another cooldownMs. Within the cooldown no HTTP attempt is made. It applies to any disabled registered endpoint (whether disabled by the breaker or a 410 Gone); cooldownMs: 0 (the default) keeps recovery manual.
Trial concurrency. The probe is a single delivery only under
ordering: "per-endpoint"(with one dispatcher instance), which serialises to at most one in-flight delivery per endpoint. Under the defaultordering: "none"every currently-duependingrow for the endpoint is admitted as a trial at once, so a still-fragile endpoint can see a burst on recovery — use"per-endpoint"ordering if you need the recovery probe to be a single request.
Logging & observability
The dispatch path is fail-open: delivery, claim, and reclaim failures are never thrown — they are sent to the logger, which defaults to a no-op. If you don't inject a logger, routine delivery problems are silent, so createRelay prints a one-time startup warning when none is set. The two critical categories are an exception: a security event (an SSRF block) and data loss (a message reaching the DLQ) fall back to console.warn/console.error even with no logger configured — and say so — so a config slip can never silence them. In production, always pass a logger anyway to capture everything. The bundled createConsoleLogger() is a safe copy-paste default:
import { createRelay, createConsoleLogger } from "commitcourier";
const relay = await createRelay({ store, logger: createConsoleLogger() });Any object matching the Logger interface works too (e.g. to bridge to pino/winston):
const relay = await createRelay({
store,
logger: {
debug: (msg, meta) => console.debug(msg, meta),
info: (msg, meta) => console.info(msg, meta),
warn: (msg, meta) => console.warn(msg, meta),
error: (msg, meta) => console.error(msg, meta),
},
});The logger also surfaces startup warnings for dangerous-but-valid config (e.g. a disabled SSRF guard). You can inject clock?: () => Date too — useful for deterministic tests.
OpenTelemetry (tracing & metrics)
The optional commitcourier/otel adapter maps deliveries onto OpenTelemetry spans and metrics. It depends on @opentelemetry/api as an optional peer dependency, so the main entry never pulls OTel into scope. Wire the result into createRelay:
import { trace, metrics } from "@opentelemetry/api";
import { createRelay } from "commitcourier";
import { createOtelInstrumentation } from "commitcourier/otel";
const { instrument, hooks } = createOtelInstrumentation({
tracer: trace.getTracer("commitcourier"),
meter: metrics.getMeter("commitcourier"),
});
const relay = await createRelay({ store, instrument, hooks });Each delivery attempt emits one CLIENT span with secret-free attributes (webhook.id, webhook.event_type, webhook.attempt, http.response.status_code, server.address / server.port, endpoint.id); the same outcome increments a commitcourier.deliveries counter (outcome = delivered | retry | dead) and records a commitcourier.delivery.duration histogram. The seam is fail-open: an instrumentation error is logged and swallowed, never stalling the dispatcher. For low-level use you can pass your own instrument / hooks without OTel.
The counter and histogram are recorded per delivery attempt (each retry counts again, plus the rare re-delivery after a worker crash) — they are attempt counts, not unique-row counts.
Low-latency delivery (accelerator)
By default the dispatcher polls, so a row enqueued onto a quiet queue waits up to pollIntervalMs before delivery starts. The optional accelerator cuts that wait: each enqueue wakes a listening dispatcher so delivery begins near-immediately. The outbox row stays the single source of truth — a missed wake only delays delivery (the poller still reclaims the row), so the accelerator never affects correctness or availability.
Idle polling cost at scale. With the default 1s
pollIntervalMs, every running dispatcher issues ~1 claim query/second even when the queue is idle, so N instances mean N queries/second againstwebhook_outbox. Each is cheap (the partial indexix_outbox_due WHERE status = 'pending'keeps the scan small), but not free. When you run many always-on dispatchers, pair the accelerator below with a longerpollIntervalMs(e.g.10_000): wakes drive low-latency delivery and polling becomes a slow safety-net fallback rather than the primary path.
The first implementation, commitcourier/accelerator/pg, uses Postgres LISTEN/NOTIFY (no extra infrastructure). The NOTIFY rides the enqueue transaction, so a listener never wakes before the row is visible; the LISTEN runs on its own self-healing connection.
import { Pool, Client } from "pg";
import { createRelay } from "commitcourier";
import { postgresStore } from "commitcourier/store/pg";
import { createPgAccelerator } from "commitcourier/accelerator/pg";
const pool = new Pool(/* … */);
const accelerator = createPgAccelerator({
pool,
// A dedicated connection for LISTEN (must NOT come from the delivery pool):
listen: async () => {
const c = new Client(/* … */);
await c.connect();
return c;
},
});
const relay = await createRelay({ store: postgresStore({ pool }), accelerator });
// Every dispatcher this relay creates now wakes on enqueue:
relay.createDispatcher({ pollIntervalMs: 10_000 }).start();pg is the only peer needed (already required by the pg store). A BullMQ accelerator is a planned future adapter on the same Accelerator seam.
Two operational notes: (1) the transactional wake rides your enqueue transaction, so in the rare case the NOTIFY itself fails, enqueue / enqueueMany roll back with your business write (fail-closed) — enqueueUnsafe swallows it. (2) If the LISTEN connection degrades without surfacing an error, wakes are simply missed and delivery falls back to polling (bounded by pollIntervalMs); correctness is unaffected because the poller remains the source of truth.
Data retention
CommitCourier never deletes rows automatically — webhook_outbox (including delivered/dead/cancelled rows) and webhook_delivery_attempts grow over time — so schedule your own pruning. Use the built-in relay.prune(...) from a cron/scheduled job:
// Delete terminal rows older than 30 days, in bounded batches. Ledger attempts cascade.
const cutoff = new Date(Date.now() - 30 * 86_400_000);
let res = await relay.prune({ olderThan: cutoff });
while (res.deleted === 10_000) res = await relay.prune({ olderThan: cutoff }); // page until drainedprune only deletes non-active statuses (default delivered / dead / cancelled; pass statuses to narrow or to include observed). A pending / in_flight row is never deleted — passing one fails as INVALID_ARGUMENT. Each call is bounded by limit (default 10 000, capped at 100 000) and returns { deleted }; when it equals the limit, call again to keep pruning. Deleting an outbox row cascades to its ledger attempts (ON DELETE CASCADE). You can still prune with raw SQL if you prefer.
CLI: commitcourier doctor
A readiness check for local dev and CI. It inspects the database (schema, applied migrations, dispatch indexes, queue health) and your configuration (which settings are at their defaults, which recommended-but-optional ones are unset and why that matters, and any risky settings):
# Database + config readiness (uses $DATABASE_URL; the DB checks need the `pg` peer dep):
npx commitcourier doctor
# Config readiness only (no DB), or inspect a specific config file, or machine-readable output:
npx commitcourier doctor --skip-db
npx commitcourier doctor --config ./commitcourier.config.js # default-exports a partial config
npx commitcourier doctor --jsonIt exits non-zero when the core tables are missing or the config is invalid, so you can gate a deploy on it. Example (abridged):
Database
[ ok ] core tables present
[warn] pending migrations: 002_… — run migrate()
[ ok ] dispatch indexes present
[info] queue: pending=3 in_flight=0 delivered=120 dead=2 …
[warn] 2 dead rows in the DLQ
Configuration
[warn] logger: unset — the default logger is a no-op, so delivery/claim errors are SILENT in production
[info] circuitBreaker.failureThreshold: 0 — failing endpoints are never auto-disabled
[ !! ] doctor: problems found (see above)Inspecting the DLQ (dead rows)
Use the read-only relay.list({ filter }) API to inspect dead rows before replaying. It returns secret-free rows newest-first (by a monotonic seq) with keyset pagination — replay({ filter }) then re-enqueues the ones you choose (a write):
// First page of the DLQ, newest first.
const { items, nextCursor } = await relay.list({ status: "dead", limit: 100 });
for (const r of items) {
console.log(r.id, r.eventType, r.attempts, r.lastError);
}
// Next page (when nextCursor is non-null).
if (nextCursor) await relay.list({ status: "dead", limit: 100, cursor: nextCursor });list accepts { status, since, endpointId, limit, cursor } and never returns the signing-key snapshot. (You can still query webhook_outbox directly if you prefer raw SQL.)
Scope your replays.
replay({ filter })selects every matching row and inserts the copies in a single transaction. On a very large DLQ that means a big in-memory result and one long-running transaction, so narrow the filter (e.g. bysinceorendpointId) and replay in chunks rather than re-enqueuing hundreds of thousands of rows at once.
Error handling
Every error the library throws is a RelayError with a stable, machine-readable code:
| Code | Thrown by | Meaning |
| ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| CONFIG_INVALID | createRelay (startup) | Invalid configuration (fail-fast). |
| MISSING_TABLES | createRelay (startup) | Core tables are absent — run store.migrate(). |
| ENQUEUE_NO_TARGET | enqueue / enqueueUnsafe | Neither { url, secret } nor { endpointId } was provided. |
| ENQUEUE_INVALID_PAYLOAD | enqueue / enqueueUnsafe | Payload is not JSON-serializable (circular reference, BigInt, …) or exceeds maxPayloadBytes. |
| INVALID_ARGUMENT | list / get / cancel / replay / prune / endpoints.list | An argument was malformed (e.g. a non-numeric cursor, unknown status, a non-uuid id, a bad olderThan). |
| SSRF_BLOCKED | dispatch (recorded, not thrown) | Destination resolved to a blocked range. |
| ENDPOINT_NOT_FOUND | dispatch (recorded, not thrown) | endpointId is not registered. |
| ENDPOINT_DISABLED | dispatch (recorded, not thrown) | The registered endpoint is disabled. |
| MISSING_SECRET | dispatch (recorded, not thrown) | An inline destination has no stored secret to sign with. |
The split mirrors the architecture: enqueue-path errors are thrown so they roll back your transaction (fail-closed), while dispatch-path failures are recorded in the ledger and retried, never thrown into your app (fail-open). Inspect the latter with relay.attempts({ outboxId }).
Verifying signatures (receiver side)
Each delivery POSTs JSON with these headers:
| Header | Value |
| ------------------- | --------------------------------------------------------- |
| webhook-id | The outbox row id (the signature's message id). |
| webhook-timestamp | Unix seconds. |
| webhook-signature | v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}. |
| content-type | application/json. |
| idempotency-key | Present only if you supplied one at enqueue time. |
Because this is the Standard Webhooks convention, your receiver can verify it with any compatible verification library — CommitCourier does not invent its own scheme. For convenience the pure, dependency-free verifySignature helper ships in commitcourier/core (handy for internal service-to-service webhooks and integration tests, with no need to add a separate verification dependency):
import { verifySignature } from "commitcourier/core";
// `rawBody` is the exact request body string, before JSON.parse.
const ok = await verifySignature({
id: req.headers["webhook-id"],
timestamp: req.headers["webhook-timestamp"],
payload: rawBody,
header: req.headers["webhook-signature"],
secrets: [endpointSecret], // pass both keys during a rotation window
});
if (!ok) return res.status(400).end(); // stale timestamp, bad signature, or no matchIt returns false (never throws) for a stale timestamp (default tolerance 300s, override with toleranceSec), a missing/garbled signature, or no match, and accepts multiple secrets so a receiver verifies either key across a rotation.
Body normalization. The
payloadis stored as Postgresjsonb, so the delivered body is the JSON round-trip of what you enqueued (object key order is not preserved, duplicate keys collapse, insignificant whitespace is dropped). The signature is always computed over the exact bytes sent, so verification never fails because of this — but the delivered bytes are not guaranteed identical to your input. If you need byte-exact delivery, enqueue the payload as a pre-serialized string.
Guarantees & non-goals
Guarantees
- No phantom / lost webhooks from dual-write — the outbox row is atomic with your business transaction.
- No event loss on process crash — at-least-once via visibility-timeout reclaim.
- No concurrent double-claim across instances —
FOR UPDATE SKIP LOCKEDstops two dispatchers grabbing the same row at once. Delivery is still at-least-once, not exactly-once: a crash after a successful HTTP send but before the status commit is redelivered once the visibility-timeout reclaim kicks in (see Non-goals). - Tamper / spoof detection — Standard Webhooks signatures.
- Outbound SSRF: common private, loopback, link-local, metadata, and other non-public targets are blocked by default (best-effort, not an absolute guarantee — see the security policy).
Non-goals (called out honestly)
- Exactly-once effects at the receiver. CommitCourier provides at-least-once + an idempotency key; final dedup is the receiver's responsibility.
- Total ordering across an endpoint. Default delivery is unordered (per-endpoint FIFO is available as an opt-in feature:
createDispatcher({ ordering: "per-endpoint" })). - Unbounded scale. This targets small-to-medium volume on your existing Postgres, not billions/sec.
- Encryption-key management. At-rest encryption of signing secrets is a precondition you must meet — via DB disk encryption, column encryption, or a
cipher(see Configuration). When you use acipher, managing the key itself (storage, distribution, rotation) is yours. Without acipher,createRelaywarns at startup and at-rest encryption is your database's responsibility; acknowledge withunsafeAllowPlaintextSecrets: true. - Inbound webhook receiving (an HTTP server / framework integration) and a customer-facing management portal UI. A receiver-side
verifySignaturehelper is provided (see Verifying signatures); standing up the endpoint is yours.
Migrations
store.migrate() applies the schema. It creates three business tables plus one migration-tracking table (four total) in your existing database:
| Table | Purpose | Retention |
| --------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------- |
| webhook_outbox | The queue + source of truth; one row per enqueued event. | Prune terminal rows with relay.prune. |
| webhook_delivery_attempts | Append-only delivery ledger; one row per attempt (cascades from outbox). | Removed with its outbox row (ON DELETE CASCADE). |
| webhook_endpoints | Optional registered-endpoint registry (only the registered-endpoint flow). | Long-lived config; not pruned. |
| commitcourier_migrations | Tracks which migrations have been applied. Not your data — never pruned. | Permanent. |
Policy:
- Forward-only. Migrations are applied in order and are idempotent (re-running
migrate()is safe and a no-op once applied — it records each applied script incommitcourier_migrationsand runs only the not-yet-applied ones). There are no down/rollback scripts; roll forward. - Concurrency-safe.
migrate()takes a Postgres transaction-scoped advisory lock (pg_advisory_xact_lock), so running it from several instances at deploy time serialises rather than racing. - Expand-and-contract. Schema changes avoid immediately dropping or renaming existing columns, so an old app version and a new schema can co-exist during a rolling deploy.
- When to run. Run it once at deploy time (a release/CI step or app boot before the dispatcher starts) — not on every request.
commitcourier doctorreports any pending migrations. - Large existing databases. On a fresh install the tables are empty, so the DDL is instant. If a future migration adds an index or
ALTERs a column on an already-largewebhook_outbox, note thatmigrate()builds indexes with a plainCREATE INDEX(notCONCURRENTLY, which cannot run insidemigrate()'s transaction) — that takes a write lock for the duration of the build. Run it in a low-traffic window from a single instance, or apply the equivalentCREATE INDEX CONCURRENTLYby hand out-of-band before deploying.
Removing CommitCourier
CommitCourier is non-invasive and reversible. Everything lives in the four dedicated tables above (webhook_outbox, webhook_delivery_attempts, webhook_endpoints, commitcourier_migrations) — all prefixed and namespaced. Stop the dispatcher, remove the enqueue calls, and drop those tables — your business schema is untouched.
API surface
| Import | Exports |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| commitcourier | createRelay, createConsoleLogger, the Relay/RelayInit types, the Store port (plus its capability roles OutboxEnqueueStore / DispatchStore / EndpointStore / OutboxQueryStore / ReplayStore / MaintenanceStore / SchemaStore), and all domain types. |
| commitcourier/core | The pure, dependency-free domain layer (sign, verifySignature, createConsoleLogger, backoffMs, state transitions, SSRF helpers, resolveConfig, RelayError, types). Importing it pulls in no driver and no node:* builtin. |
| commitcourier/store/pg | postgresStore({ pool }) — Store<PoolClient>. |
| commitcourier/store/knex | knexStore({ knex }) — Store<Knex.Transaction>. |
| commitcourier/store/drizzle | drizzleStore({ db }) — Store<DrizzleTx> (Drizzle on node-postgres). |
| commitcourier/store/prisma | prismaStore({ prisma }) — Store<PrismaTx> (Prisma interactive transaction). |
| commitcourier/otel | createOtelInstrumentation({ tracer, meter }) — optional OpenTelemetry instrumentation, passed as createRelay({ instrument, hooks }). |
| commitcourier/accelerator/pg | createPgAccelerator({ pool, listen }) — optional low-latency wake via Postgres LISTEN/NOTIFY, passed as createRelay({ accelerator }). |
| commitcourier/forward (experimental) | The Sink port and SinkEvent / SinkResult types for the sink transport — see Experimental: webhook-SaaS handoff. API may change in a minor release. |
| commitcourier/forward/svix (experimental) | svixSink(...) — official sample Sink adapter for Svix (svix optional peer). API may change in a minor release. |
Key signatures:
function createRelay<TTx>(init: RelayInit<TTx>): Promise<Relay<TTx>>;
interface Relay<TTx> {
enqueue(trx: TTx, input: EnqueueInput): Promise<{ id: string }>;
enqueueMany(trx: TTx, inputs: EnqueueInput[]): Promise<{ ids: string[] }>;
enqueueUnsafe(input: EnqueueInput): Promise<{ id: string }>;
createDispatcher(options?: DispatcherOptions): Dispatcher;
dispatchOnce(
options?: DispatcherOptions,
runOptions?: RunOnceOptions,
): Promise<{ processed: number }>;
attempts(opts: { outboxId: string }): Promise<DeliveryAttempt[]>;
replay(
opts: { outboxId: string } | { filter: ReplayFilter },
): Promise<{ ids: string[]; capped: boolean }>;
cancel(outboxId: string): Promise<{ cancelled: boolean }>;
get(outboxId: string): Promise<OutboxListItem | null>;
list(filter?: OutboxListFilter): Promise<Page<OutboxListItem>>;
prune(opts: PruneOptions): Promise<{ deleted: number }>; // retention: delete old terminal rows
stats(): Promise<OutboxStats>;
endpoints: EndpointAdmin; // register / update / enable / disable / get / list
}Experimental: webhook-SaaS handoff (sink transport)
⚠️ Experimental. This surface is exported but not yet covered by the stability guarantee — it may change in a minor release.
Instead of delivering over HTTP itself, CommitCourier can hand each event to an external webhook-delivery SaaS (Svix, Outpost, Hookdeck, …) while the atomic, at-least-once enqueue still rides your transaction. Set the delivery transport to sink and pass a Sink:
import { Svix } from "svix";
import { createRelay } from "commitcourier";
import { svixSink } from "commitcourier/forward/svix"; // or your own Sink
const relay = await createRelay({
store,
delivery: { transport: "sink" },
sink: svixSink({ svix: new Svix(process.env.SVIX_TOKEN!), appId: "app_..." }),
});In sink mode, signing / SSRF / circuit breaker are delegated to the SaaS. Implement the Sink port (commitcourier/forward) yourself to target any other provider.
Feature status
CommitCourier is pre-1.0 (0.x). During 0.x, a minor release may contain breaking changes; the table sets expectations per surface (see Compatibility & support for the full policy). The full list of shipped capabilities is in Features and the CHANGELOG.
| Stability | Surface |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Stable | Transactional enqueue, the HTTP dispatcher, the pg / Knex / Drizzle / Prisma stores, retry / backoff / jitter, the delivery-attempts ledger, the DLQ, Standard Webhooks signing, SSRF protection, and at-rest secret encryption. |
| Beta — may change in a minor | The registered-endpoint admin API, the circuit breaker, the registered-endpoint cache, the OpenTelemetry adapter, the LISTEN/NOTIFY accelerator, replay, retention/pruning, cancel, and the doctor CLI. |
| Experimental — may change in a minor (opt-in subpath) | The generic sink transport (commitcourier/forward) and the Svix sample adapter (commitcourier/forward/svix). |
Compatibility & support
| Dependency | Supported |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node.js | 22.19.0+. |
| PostgreSQL | 12+ (the minimum; CI integration tests run against PostgreSQL 12, 16, and 17). |
| Adapters | pg, knex, drizzle-orm, and @prisma/client are optional peer dependencies — install only the one you use; ranges are declared in peerDependencies. |
- SemVer in
0.x. Per SemVer, a minor (0.y) release may include breaking changes during0.x. Stable surfaces above are changed conservatively with CHANGELOG notes; Beta and Experimental surfaces are where breaking changes are most likely. - Breaking changes are called out in the CHANGELOG; once stabilised at
1.0, the public API will follow SemVer in the usual way. - Security fixes and the supported-version / private-reporting policy live in the security policy.
Roadmap
- Toward 1.0: stabilise the Beta surfaces and decide whether the
sinktransport graduates from experimental (a stable-API commitment) or stays a thin handoff. - On the existing seams: a BullMQ accelerator and further endpoint-management APIs, both building on the
Accelerator/ admin seams already in place.
Security
Found a vulnerability? Please don't open a public issue — report it privately as described in the security policy. That document also covers the security model (SSRF defaults, signing, secret handling) and what is in vs out of scope.
