Hookline documentation
Hookline sends and receives webhooks from inside your application. Events are written to your database in the same transaction as your data, then signed, retried and delivered by workers you run in your existing processes.
Start here
- Getting started: install, migrate, send your first webhook.
Sending
- Sending events:
send(), event types, idempotency, ordering, scheduling, the event catalog. - Endpoints: creating endpoints, routing, auth, secrets and rotation, test sends, verification.
- Delivery: workers, retries, dead letters, the circuit breaker, rate limits, guarantees.
- Payloads: versions, thin payloads, transforms, batching.
- Destinations: SQS, SNS, EventBridge, S3, Pub/Sub, Service Bus, Kafka, RabbitMQ, NATS, Redis.
Receiving
- Inbox: verifying and processing webhooks from Stripe, GitHub, Shopify and 22 others.
- For your customers: how to verify webhooks that Hookline sends.
Running it
- Customer portal: tokens, the portal API and the
<hookline-portal>component. - Storage: Postgres, MySQL, SQLite, ORMs, the schema.
- Operations: metrics, tracing, alerts, stats, retention, proxies, chaos mode.
- CLI: every
hooklinecommand. - Security: signing, SSRF protection, encryption at rest, portal tokens.
Reference
- Configuration: every option, with defaults.
- Errors: every error code.
- Architecture: how it works inside.
Getting started
Requirements: Node.js 18.17 or newer, and Postgres 12 or newer. (MySQL 8 and SQLite also work; see Storage.)
Install #
npm i @hookline/core @hookline/postgres pgCreate an instance #
// hooks.ts
import pg from 'pg';
import { createHookline } from '@hookline/core';
import { postgresStorage } from '@hookline/postgres';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const hooks = createHookline({ storage: postgresStorage({ pool }) });Create the tables #
await hooks.migrate();migrate() creates a hookline schema and its tables, or upgrades them. It is safe to call on every boot and
from several processes at once.
Start a worker #
const worker = hooks.worker().start();
process.on('SIGTERM', async () => {
await worker.stop(); // finishes attempts in flight
await hooks.close(); // releases the LISTEN connection
await pool.end();
});The worker fans out new events, claims deliveries that are due, sends them and records the result. Run one in
every process that should deliver, or a dedicated worker process. No long-running process? Call
hooks.process() from a cron job instead (see Delivery).
Add an endpoint #
Endpoints belong to a tenant: your identifier for the customer, usually an account id.
const { endpoint, secret } = await hooks.admin.endpoints.create({
tenantId: 'acct_42',
url: 'https://customer.example.com/webhooks',
eventTypes: ['order.*'],
});
console.log(secret.secret); // "whsec_...": give it to the customer so they can verify signaturesSend an event #
Pass the same client you use for the data change. The event commits or rolls back with it.
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: [order] } = await client.query(
'INSERT INTO orders (account_id, total) VALUES ($1, $2) RETURNING *', ['acct_42', 2400]);
await hooks.send(client, { tenantId: 'acct_42', type: 'order.paid', data: order });
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}The endpoint receives:
POST /webhooks HTTP/1.1
content-type: application/json
user-agent: Hookline/0.1.0 (+https://hookline.dev)
webhook-id: msg_01JABCDEF0123456789ABCDEFG
webhook-timestamp: 1760600000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"order.paid","timestamp":"2026-10-16T09:33:20.000Z","data":{"id":17,"account_id":"acct_42","total":2400}}See what happened #
const { data } = await hooks.admin.deliveries.list({ tenantId: 'acct_42' });
const attempts = await hooks.admin.attempts.list({ deliveryId: data[0].id });Or from a terminal: DATABASE_URL=... npx hookline status.
Next #
Sending events
hooks.send(tx, input) #
Records an event for delivery. Returns { id, duplicate }.
await hooks.send(client, {
tenantId: 'acct_42',
type: 'order.paid',
data: { id: 'ord_1', total: 2400 },
idempotencyKey: 'order-paid-ord_1', // optional
orderingKey: 'ord_1', // optional
deliverAt: new Date(Date.now() + 60_000), // optional
});| Field | Type | |
|---|---|---|
tenantId | string, 1β255 chars | The customer the event belongs to. Only that tenant's endpoints receive it. |
type | string | Event type, e.g. order.paid. |
data | any JSON value | The payload. |
idempotencyKey | string, 1β255 chars | Sending twice with the same key for the same tenant stores one message. The second call returns the first message's id and duplicate: true. |
orderingKey | string, 1β255 chars | Messages with the same key reach each endpoint one at a time, in the order sent. |
deliverAt | Date | Don't deliver before this time. Endpoints are matched when it comes due. |
The first argument is the connection or transaction handle your data change uses: a pg PoolClient or
Client, a mysql2 connection, a node:sqlite database, or an ORM transaction wrapped with one of the
helpers. Passing the pool itself also works, but then the event is committed on its own.
send() does one insert. Matching the event to endpoints happens afterwards in the worker, so a slow fan-out
never holds your transaction open, and a failure there never rolls your data back.
What gets stored #
The body is serialised once, at send time, and stored as text. With the default envelope it is:
{ "type": "order.paid", "timestamp": "2026-10-16T09:33:20.000Z", "data": { "id": "ord_1", "total": 2400 } }timestamp is when send() was called. Set envelope: false in the config to send data on its own.
The stored text is exactly what is signed and sent, unless the endpoint uses payload features.
Limits and validation #
- Bodies over
maxPayloadBytes(1 MiB by default) throwpayload_too_large. - Values that can't be serialised to JSON (circular references,
BigInt) throwinvalid_payload. send()throwsinvalid_sendwhen the first argument is missing.
Event types #
Names are letters, digits, _ and -, separated by dots, up to 200 characters: order.paid,
invoice.payment_failed, user.profile.updated.
Endpoints subscribe with patterns:
| Pattern | Matches |
|---|---|
order.paid | exactly order.paid |
order.* | one segment: order.paid, order.refunded, not order.item.added |
order.** | any depth under order, and order itself |
* or ** | everything |
An endpoint with eventTypes: null (the default) receives every event.
The event catalog #
The catalog documents your event types. The portal shows it, test sends use its examples, AsyncAPI is built from it, and payloads can be validated against it.
await hooks.admin.eventTypes.upsert({
name: 'order.paid',
description: 'Payment for an order succeeded.',
schema: { type: 'object', required: ['id', 'total'], properties: { id: { type: 'string' }, total: { type: 'integer' } } },
example: { id: 'ord_1', total: 2400 },
});| Method | Returns |
|---|---|
eventTypes.upsert({ name, description?, schema?, example?, deprecated? }) | EventType. Fields you leave out keep their stored value. |
eventTypes.get(name) | EventType or null |
eventTypes.list({ includeDeprecated?, limit?, cursor? }) | page, sorted by name; deprecated types are hidden unless asked for |
eventTypes.delete(name) | true if it existed |
Validating payloads #
Hookline doesn't ship a JSON Schema validator. Bring one and pass it as validator; it's called with the event
type's schema when one exists, and throwing rejects the send with invalid_payload.
import Ajv from 'ajv';
const ajv = new Ajv();
const hooks = createHookline({
storage,
validator: (schema, data) => {
const check = ajv.compile(schema);
if (!check(data)) throw new Error(ajv.errorsText(check.errors));
},
});Schemas are cached for 30 seconds per process. Types without a schema, or not in the catalog, are not validated.
Endpoints
An endpoint is somewhere a tenant's events go: an HTTPS URL, or a queue or topic through a destination.
Creating one #
const { endpoint, secret } = await hooks.admin.endpoints.create({
tenantId: 'acct_42',
url: 'https://customer.example.com/webhooks',
description: 'Production',
eventTypes: ['order.*', 'invoice.paid'],
filter: { path: 'data.total', op: 'gte', value: 1000 },
});create() returns the endpoint and its signing secret. The tenant row is created if it doesn't exist.
| Field | Default | Limits and notes |
|---|---|---|
tenantId | required | 1β255 chars |
url | required for HTTP | https:// (or http:// with allowHttp), at most 2048 chars, no user:pass@, no #fragment. IP literals in private ranges and localhost, *.local, *.internal are rejected unless your SSRF settings allow them. |
description | null | |
eventTypes | null (everything) | up to 200 patterns |
filter | null | see Filters |
headers | {} | up to 32 extra headers; see Headers and auth |
auth | null | { type: 'bearer', token }, { type: 'basic', username, password } or { type: 'header', name, value } |
signing | 'hmac' | 'hmac' (HMAC-SHA256) or 'ed25519' |
secret | generated | bring your own whsec_... secret (24β64 bytes) when migrating from another system; HMAC only |
rateLimit | null | { perSecond, burst }; perSecond up to 10,000; burst 1β10,000, default 1 |
maxInFlight | null | 1β1000 attempts at once |
timeoutMs | config timeoutMs | 1,000β120,000 |
retryPolicy | config retryPolicy | see Retries |
metadata | {} | any JSON object under 16 KB, for your own use |
disabled | false | create it switched off |
destination | null (HTTP) | { type, config }; see Destinations |
version | latest configured | see Versions |
payloadMode | 'full' | 'thin'; see Thin payloads |
transform | null | see Transforms |
batch | null | { maxSize, maxWaitMs }; see Batching |
Reading and changing #
| Method | Returns |
|---|---|
endpoints.get(id) | Endpoint or null |
endpoints.list({ tenantId?, limit?, cursor? }) | page, newest first |
endpoints.update(id, changes) | Endpoint. Any create field except tenantId, secret, signing and disabled. Pass null to clear a field. Changing the URL clears verifiedAt. |
endpoints.delete(id) | true if it existed. Pending deliveries and the endpoint's history go with it. |
endpoints.disable(id, reason?) | Endpoint. New events skip it; pending deliveries wait. |
endpoints.enable(id) | Endpoint. Clears the circuit breaker and failure tracking; pending deliveries resume. |
Returned endpoints never include secrets or auth credentials. auth shows only the type, e.g. { type: 'bearer' }.
The circuit field shows { state, consecutiveFailures, openUntil } and failingSince shows when the current
run of failures began.
Filters #
A filter selects events by payload. It's evaluated against the whole body, so paths usually start with data..
{ path: 'data.total', op: 'gte', value: 1000 }
{ all: [ { path: 'type', op: 'startsWith', value: 'order.' }, { not: { path: 'data.currency', op: 'eq', value: 'gbp' } } ] }
{ any: [ { path: 'data.plan', op: 'in', value: ['pro', 'team'] }, { path: 'data.tags', op: 'contains', value: 'vip' } ] }op | True when |
|---|---|
eq, neq | equal, not equal (deep equality for objects and arrays) |
gt, gte, lt, lte | both sides are numbers, or both strings, and the comparison holds. No type coercion. |
in, nin | value is (not) in the given array |
exists | the path exists. value: false inverts it. A path holding null exists. |
contains | a string contains the substring, or an array contains the element |
startsWith, endsWith | string prefix or suffix |
Paths are dotted; numbers index arrays (data.items.0.sku). Combine with all, any and not. A filter may
hold at most 100 rules, nested at most 10 deep. There are no regular expressions, so a filter can't be made slow.
Headers and auth #
headers are added to every request. Names must be valid HTTP tokens; values must be one line of at most 4096
characters. These names are reserved and rejected: host, content-length, content-type, transfer-encoding,
connection, user-agent, authorization and anything starting with webhook-.
auth sets the Authorization header (bearer, basic) or one custom header (header). Credentials are
encrypted at rest when encryption is configured.
Signing secrets #
const view = await hooks.admin.endpoints.secret(id);
// { scheme: 'hmac', secret: 'whsec_...', publicKey: null, previous: [] }For ed25519 endpoints, secret is null and publicKey is the whpk_... key receivers verify with. The
private key never leaves your database.
Rotation #
const view = await hooks.admin.endpoints.rotateSecret(id, { overlapMs: 24 * 3600_000 });
// view.secret is new; view.previous lists the old one and when it stops being usedDuring the overlap every request carries two signatures, one per secret, so the receiver can switch over at
any time without failing a single delivery. overlapMs defaults to 24 hours and may be 0 to 30 days; with 0
the old secret stops immediately. At most 5 secrets are kept. Pass { secret: 'whsec_...' } to rotate to a
secret you supply.
Test sends #
await hooks.admin.endpoints.sendTest(id); // a "webhook.test" event
await hooks.admin.endpoints.sendTest(id, { type: 'order.paid' }); // the catalog example for order.paid
await hooks.admin.endpoints.sendTest(id, { type: 'order.paid', data: { id: 'ord_test' } });Returns { messageId }. The message goes to this endpoint only, ignoring its event types and filter, and is
delivered by a worker like any other.
Ownership verification #
const { verified, error } = await hooks.admin.endpoints.verify(id);Sends a signed POST with this body and waits for the answer:
{ "type": "endpoint.verification", "timestamp": "...", "data": { "challenge": "01JAB..." } }The endpoint is verified if it answers 2xx with the challenge string anywhere in the response body. On success
verifiedAt is set. With requireVerifiedEndpoints: true in the config, unverified endpoints receive nothing
(test sends excepted). Changing an endpoint's URL clears verifiedAt.
Tenants #
| Method | Returns |
|---|---|
tenants.upsert({ id, name?, metadata? }) | Tenant. Fields you leave out keep their value. |
tenants.get(id) | Tenant or null |
tenants.list({ limit?, cursor? }) | page |
tenants.delete(id) | true if it existed. Deletes the tenant's endpoints, messages, deliveries and attempts. Use it for account deletion. |
Pagination #
Every list() takes limit (default 50, at most 500) and cursor, and returns { data, nextCursor }, newest
first. Pass nextCursor back to get the next page; it's null on the last one.
Delivery
Workers #
const worker = hooks.worker({ concurrency: 32 }).start();
// later
await worker.stop();A worker runs two loops. The dispatch loop matches new messages to endpoints and creates one delivery per endpoint. The delivery loop claims due deliveries, sends them and records each attempt.
| Option | Default | |
|---|---|---|
concurrency | 32 | attempts in flight at once from this process |
perEndpoint | 8 | most deliveries one endpoint can take in a single claim, so one busy endpoint can't take every slot |
leaseMs | max(60 s, 2 Γ timeoutMs) | how long a claimed delivery stays reserved; must be longer than the timeout |
pollMs | 1000 | fallback poll interval |
dispatchBatch | 200 | messages fanned out per batch |
retentionMs | null (keep forever) | delete finished history older than this, every pruneEveryMs |
pruneEveryMs | 600,000 | |
workerId | hostname + random | recorded on every attempt |
outbox | true | false runs only extensions (e.g. an inbox-only worker) |
extensions | [] | extra loops; inbox.extension() adds inbox processing |
With Postgres, workers wake as soon as a transaction that called send() commits (LISTEN/NOTIFY), so
pollMs only matters for retries coming due. Run as many workers as you like, on as many machines; they never
claim the same delivery.
worker.stop(graceMs = 30_000) stops claiming and waits for attempts in flight. Anything still unfinished is
picked up by another worker when its lease runs out.
No long-running process? #
const result = await hooks.process({ maxMs: 50_000 });
// { dispatched, attempted, succeeded, failed }Does everything due, then returns, stopping early at maxMs (default 25 s). Call it from a cron job or a
scheduled function, every minute or so. It takes the worker options too; concurrency defaults to 16.
The request #
Each attempt is a POST with:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Hookline/0.1.0 (+https://hookline.dev) unless you set userAgent |
webhook-id | the message id; the same on every retry, so receivers can drop duplicates |
webhook-timestamp | seconds since the epoch, taken when this attempt was signed |
webhook-signature | v1,<base64> (HMAC) or v1a,<base64> (Ed25519); several, space separated, during a rotation |
webhook-version | the payload version, when the endpoint is pinned to one |
plus the endpoint's headers and auth. See For your customers for how receivers verify.
An attempt succeeds when the endpoint answers 2xx within the timeout (15 s by default). Everything else fails: other status codes, timeouts, DNS and connection errors, TLS errors, and redirects, which are never followed (a redirect could point a public URL at a private address).
Each attempt is recorded with its status code, duration, error kind, the remote address, the worker, a few
response headers (content-type, content-length, retry-after, location, server, date, x-request-id,
cf-ray) and the first 4 KB of the response body (maxResponseBytes).
errorKind | Meaning |
|---|---|
http | the endpoint answered, but not with 2xx (including 3xx redirects) |
timeout | no complete response within the timeout |
dns | the hostname didn't resolve |
connection | refused, reset, unreachable, or the request couldn't be prepared |
tls | certificate or handshake problem |
blocked | the address is in a range the SSRF guard refuses |
invalid_url | the stored URL can't be used (wrong protocol, unparseable) |
destination | a non-HTTP destination reported a failure |
chaos | failure injected by chaos mode |
Retries #
A failed attempt is retried on the endpoint's retryPolicy, or the config's. The default is a schedule:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Waits before it | β | 5 s | 5 min | 30 min | 2 h | 5 h | 10 h | 10 h |
That's about 27 hours of cover. Policies:
{ kind: 'schedule', delaysMs: [5_000, 60_000, 600_000] } // up to 50 delays, each up to 7 days
{ kind: 'fixed', delayMs: 30_000, maxAttempts: 10 } // maxAttempts 1β50
{ kind: 'exponential', initialMs: 1_000, factor: 2, maxMs: 3_600_000, maxAttempts: 12, jitter: 'full' }jitter is 'full' (random between 0 and the delay; the default), 'equal' (half fixed, half random) or 'none'.
If the endpoint answers with Retry-After (seconds or an HTTP date), the next attempt waits at least that long,
capped at maxRetryAfterMs (1 hour).
Dead letters #
When the last attempt fails, the delivery's status becomes dead and onEvent gets a delivery.dead event.
Nothing is lost; dead deliveries stay until you prune them.
| Method | Effect |
|---|---|
deliveries.retry(id) | Sends it again now with a fresh set of retries. Works on dead, cancelled and pending. Returns 1, or 0 if it was in flight. |
endpoints.recover(endpointId, { since }) | Retries every dead or cancelled delivery to the endpoint created at or after since. Returns how many. |
messages.replay(messageId) | New deliveries to every endpoint the message matches now. |
messages.replay(messageId, { endpointId }) | A new delivery to one endpoint of the same tenant. |
deliveries.cancel(id) | Stops a pending delivery. |
Delivery statuses: pending (waiting for its first attempt or a retry), delivering (an attempt is running),
succeeded, dead, cancelled.
Endpoint health #
Circuit breaker. After failureThreshold failures in a row (10), the endpoint's circuit opens and nothing is
sent to it for openMs (30 s). Then one probe goes through. If it succeeds the circuit closes; if it fails, it
opens again for twice as long, up to maxOpenMs (30 min). Deliveries aren't failed while the circuit is open;
they wait. All workers share the state through the database. onEvent gets endpoint.circuit_opened and
endpoint.circuit_closed.
Auto-disable. An endpoint is disabled when it answers 410 Gone (disableOnGone, on by default), or when
every attempt has failed for autoDisableAfterMs (5 days). onEvent gets endpoint.disabled.
endpoints.enable(id) turns it back on.
Flow control #
Rate limits. rateLimit: { perSecond: 10, burst: 20 } lets 20 through back to back after a quiet period, then
10 a second. It's a single timestamp per endpoint (a generic cell rate algorithm), shared by all workers.
Concurrency. maxInFlight: 2 keeps at most two attempts to the endpoint running at once, across all workers.
Ordering. Messages with the same orderingKey reach each endpoint one at a time, in the order sent. A later
message waits while an earlier one is pending or being retried. If an earlier one goes dead, the next ones
proceed. Messages without a key aren't held up by ones with keys.
Deliveries held back by any of these are rescheduled for when they can go, so they don't block other endpoints.
Guarantees #
- No lost or phantom events. An event exists only if the transaction that sent it committed.
- At least once. A delivery is retried until it gets a 2xx or runs out of attempts. If a worker dies after the
endpoint received a request but before the result was saved, the request is sent again after the lease ends.
Receivers should drop duplicates by
webhook-id. - Stale workers can't overwrite. Every claim carries a token. A worker reporting after its lease was taken over has its attempt logged and its decision ignored.
- Order only where you ask for it, with
orderingKey.
Payloads
By default each endpoint receives the body exactly as send() stored it. These features change what a
particular endpoint receives. They're applied at delivery time, in this order: version downgrade, thin mode,
transform. The signature always covers the body actually sent.
Versions #
When you change the shape of an event, endpoints that integrated against the old shape can keep receiving it.
const hooks = createHookline({
storage,
versions: [
{ version: '2025-06-01' },
{ version: '2026-01-01', downgrade: {
'order.*': (data) => { const { total, ...rest } = data; return { ...rest, amount: total.value }; },
} },
{ version: '2026-10-01', downgrade: {
'order.paid': (data) => { const { customer, ...rest } = data; return { ...rest, customer_id: customer.id }; },
} },
],
});List versions oldest first. You always send() the latest shape. Each version's downgrade converts from that
version to the one before it; keys are event type patterns, and the first matching key is used. An endpoint
pinned to 2025-06-01 gets the 2026-10-01 downgrade, then the 2026-01-01 one.
New endpoints are pinned to the latest version when they're created, so later changes never break them. Move an
endpoint forward with endpoints.update(id, { version: '2026-10-01' }). Pinned endpoints get a
webhook-version header. Downgrade functions receive a copy of the data and { type, from, to }.
Thin payloads #
await hooks.admin.endpoints.create({ tenantId, url, payloadMode: 'thin' });
const hooks = createHookline({ storage, thinFields: ['id', 'status'] }); // default ['id']data is cut down to thinFields; receivers fetch the rest from your API. Useful when payloads are large or
contain data you'd rather serve with fresh authorisation.
Transforms #
transform: {
pick: ['id', 'customer.email', 'total'], // keep only these paths
omit: ['customer.internal_notes'], // remove these
rename: { 'customer.email': 'email' }, // move a value
set: { source: 'acme' }, // add fixed values
wrap: 'order', // nest everything under one key
}Every step is optional; they run in the order shown, on data. Paths are dotted. omit is how you keep a
field away from one customer's endpoint.
Transforms are declarative on purpose. Letting customers type code into a portal and running it inside your
servers isn't something Node can make safe, so Hookline doesn't do it. Limits: 100 paths per step, set values
under 16 KB, and __proto__, prototype and constructor are refused in paths.
Batching #
batch: { maxSize: 100, maxWaitMs: 5_000 }Deliveries to the endpoint wait up to maxWaitMs (0β60,000) after their message is fanned out, then up to
maxSize (1β1000) are sent together in one request:
{
"type": "hookline.batch",
"timestamp": "2026-10-16T09:33:25.000Z",
"data": [
{ "id": "msg_01JA...", "type": "order.paid", "timestamp": "...", "data": { } },
{ "id": "msg_01JB...", "type": "order.refunded", "timestamp": "...", "data": { } }
]
}The batch has its own webhook-id (bat_...); each item carries its message id for de-duplication. The whole
batch succeeds or fails together, and a failed batch counts as one failure toward the circuit breaker.
Rate limits count the batch as one request.
Destinations
An endpoint can deliver to a queue, topic, bus or bucket instead of an HTTP URL.
npm i @hookline/destinationsimport { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { sqsDestination } from '@hookline/destinations';
const sqs = new SQSClient({});
const hooks = createHookline({
storage,
destinations: { sqs: sqsDestination((input) => sqs.send(new SendMessageCommand(input))) },
});
await hooks.admin.endpoints.create({
tenantId: 'acct_42',
destination: { type: 'sqs', config: { queueUrl: 'https://sqs.eu-west-1.amazonaws.com/123456789012/orders' } },
});The key under destinations is the type endpoints use. config is checked when the endpoint is created or
updated. The endpoint's url is optional and informational (it defaults to "sqs:").
No SDK is bundled. AWS destinations take a function that calls the SDK client you already have; the others take the client object. Every destination:
- sends the rendered body (versions, thin mode and transforms applied);
- attaches
webhook-id,webhook-timestampandwebhook-signature, so consumers can verify the message even off HTTP, plus the event type; - is retried, circuit-broken and logged like HTTP. Throttling errors (
Throttling,Rate exceeded,429...) ask for a 30 second wait.
Destinations are subject to the endpoint's timeoutMs.
Reference #
sqsDestination(send) #
send(input) receives a SendMessageCommand input. Config: { queueUrl, messageGroupId? }.
Attributes: webhook-id, webhook-timestamp, webhook-signature, event-type. For FIFO queues (URL ending
.fifo) MessageGroupId is the ordering key, else messageGroupId, else the tenant, and
MessageDeduplicationId is the webhook id, so retries are de-duplicated by SQS.
snsDestination(send) #
send(input) receives a PublishCommand input. Config: { topicArn }. Same attributes. FIFO topics get a group id
(ordering key or tenant) and a deduplication id.
eventBridgeDestination(send) #
send(input) receives a PutEventsCommand input. Config: { eventBusName, source? } (source defaults to
hookline). DetailType is the event type. Detail is:
{ "webhookId": "msg_...", "webhookTimestamp": "1760600000", "webhookSignature": "v1,...", "body": { "type": "...", "data": { } } }Events over EventBridge's 256 KB limit fail without being sent. Per-entry failures inside a 200 response are treated as failures.
s3Destination(send) #
send(input) receives a PutObjectCommand input. Config: { bucket, prefix? }. Objects are written to
<prefix><tenant>/<yyyy>/<mm>/<dd>/<event type>/<webhook id>.json (date of the message, UTC), with the signature
in object metadata. A retry overwrites the same key rather than adding a copy.
pubSubDestination(pubsub) #
Pass a @google-cloud/pubsub client. Config: { topic }. Attributes add tenant-id. The ordering key is passed
through; enable message ordering on the topic to use it.
serviceBusDestination(client) #
Pass a @azure/service-bus ServiceBusClient. Config: { queueOrTopic }. The body is sent as parsed JSON;
messageId is the webhook id (so duplicate detection drops retried copies when it's on), subject is the event
type, sessionId is the ordering key, and the signature is in applicationProperties. One sender per queue is
reused.
kafkaDestination(producer) #
Pass a connected KafkaJS producer. Config: { topic }. The record key is the ordering key, or the tenant, so one
tenant's events stay in one partition in order. acks: -1. The signature is in record headers.
rabbitDestination(channel) #
Pass an amqplib confirm channel (connection.createConfirmChannel()), so a send only counts once the broker
confirms it. Config: { exchange, routingKey? }; use "" for the default exchange. The routing key defaults to
the event type. Messages are persistent, with messageId, type, timestamp and the signature in headers. A
nack is a failure.
natsDestination(js, headers) #
Pass a JetStream client (nc.jetstream()) and the headers function from the nats package. Config:
{ subject?, prefix? }. The default subject is <prefix>.<tenant>.<event type> with prefix defaulting to
hookline (dots, spaces, * and > in the tenant id become _). msgID is the webhook id, so JetStream's
duplicate window drops retried copies.
redisStreamDestination(redis) #
Pass an ioredis or node-redis (v4+) client. Config: { stream, maxLen? }. Adds an entry with fields body,
webhook-id, webhook-timestamp, webhook-signature, event-type and tenant-id. maxLen trims the stream
approximately (MAXLEN ~).
Writing your own #
import type { Destination } from '@hookline/core';
const logDestination: Destination = {
validate(config) { if (typeof config.path !== 'string') throw new Error('config.path is required'); },
async send({ body, headers, config, message, messages, endpoint, timeoutMs, signal }) {
await appendLine(String(config.path), body);
return { ok: true, response: 'appended' };
},
};send() returns { ok, error?, retryAfterMs?, response?, statusCode? }. Return ok: false (or throw) to fail
the attempt. messages holds every message in a batch. signal aborts when the timeout passes.
Inbox
The inbox receives webhooks from other services. For each request it verifies the signature, drops duplicates, stores the event and answers, then your handlers run later on your workers, with retries.
npm i @hookline/inboximport { createInbox, nodeHandler } from '@hookline/inbox';
export const inbox = createInbox({
storage, // the same postgresStorage / mysqlStorage / sqliteStorage
sources: {
stripe: { preset: 'stripe', secret: process.env.STRIPE_WEBHOOK_SECRET! },
github: { preset: 'github', secret: [process.env.GITHUB_SECRET!, process.env.GITHUB_SECRET_OLD!] },
},
});
app.post('/webhooks/stripe', nodeHandler(inbox, 'stripe')); // before express.json()
app.post('/webhooks/github', nodeHandler(inbox, 'github'));
inbox.on('stripe', 'invoice.paid', async (event) => {
await markInvoicePaid(event.payload.data.object.id);
});
hooks.worker({ extensions: [inbox.extension()] }).start();What happens to a request #
- Methods other than
POSTget405; bodies overmaxBodyBytes(1 MiB) get413; unknown sources get404. - The signature is verified with the source's preset and secrets. If it fails, or no secret is configured, the
answer is
401 {"error":"invalid_signature"}and the reason is written to the rejection log. - Verification challenges (Slack, Zoom, Twitch) are answered directly, after verifying, and not stored.
- The event is stored, unless an event with the same dedupe key already exists for the source, in which case its duplicate counter goes up. This is a single statement, so simultaneous retries can't both get in.
- The answer is
200 {"ok":true,"id":"evt_..."}, with"duplicate":truefor repeats. If storage fails the answer is500, so the provider retries: a 200 always means the event is stored.
The dedupe key is the provider's event id, or a SHA-256 of the body when the provider sends none. Stored headers
leave out authorization, cookie, proxy-authorization, x-api-key and set-cookie. Bodies that aren't valid
UTF-8 are stored as base64 and handed to handlers byte for byte.
Sources #
sources: {
shopify: {
preset: 'shopify',
secret: async (req, shopDomain) => lookupSecretForShop(shopDomain), // per-account secrets
tenant: (req, shopDomain) => shopDomain,
toleranceSeconds: 300,
retryPolicy: { kind: 'fixed', delayMs: 60_000, maxAttempts: 10 },
},
}| Field | Default | |
|---|---|---|
preset | required | a preset name, or a preset you build (see below) |
secret | required | a string, several strings (tried in turn; that's how you rotate), or a function (req, account) => string | string[] | null, which may be async |
tenant | none | (req, account) => string | null; stored as the event's tenantId |
publicUrl | the request URL | the URL the provider calls, for presets that sign the URL (Twilio, Square, HubSpot) when you're behind a proxy that changes host or scheme |
toleranceSeconds | 300 | clock skew accepted for presets that sign a timestamp |
retryPolicy | inbox retryPolicy | for this source's handlers |
Source names are letters, digits, - and _, up to 63 characters. account is the provider's account hint where
it has one (see the table). A source with no secret makes createInbox() throw.
Presets #
| Preset | Signature checked | Timestamp checked | Dedupe key | Event type |
|---|---|---|---|---|
stripe | Stripe-Signature v1: HMAC-SHA256 of t.body, keyed with the whole whsec_ string | yes | id | type (account: account) |
github | X-Hub-Signature-256: HMAC-SHA256 | no | X-GitHub-Delivery | X-GitHub-Event + .action (account: installation id) |
gitlab | X-Gitlab-Token equals the secret | no | X-Gitlab-Event-UUID | object_kind |
bitbucket | X-Hub-Signature: HMAC-SHA256 | no | X-Request-UUID | X-Event-Key |
shopify | X-Shopify-Hmac-Sha256: base64 HMAC-SHA256 | no | X-Shopify-Event-Id or X-Shopify-Webhook-Id | X-Shopify-Topic (account: shop domain) |
slack | X-Slack-Signature: HMAC-SHA256 of v0:ts:body | yes | event_id | event.type, else type (account: team_id) |
twilio | X-Twilio-Signature: base64 HMAC-SHA1 of URL + sorted form params, or of the URL with bodySHA256 for JSON | no | I-Twilio-Idempotency-Token | EventType, MessageStatus, SmsStatus or CallStatus (account: AccountSid) |
sendgrid | ECDSA P-256 over timestamp + body; the secret is the verification public key (base64 DER or PEM) | no | body hash | sendgrid.events |
mailgun | HMAC-SHA256 of timestamp + token, from the body's signature object or form fields | yes | event-data.id | event-data.event |
paddle | Paddle-Signature h1: HMAC-SHA256 of ts:body | yes | event_id | event_type |
lemonsqueezy | X-Signature: HMAC-SHA256 | no | body hash | X-Event-Name |
linear | Linear-Signature: HMAC-SHA256 | yes (webhookTimestamp in the body) | Linear-Delivery | Linear-Event + .action |
zoom | x-zm-signature: HMAC-SHA256 of v0:ts:body | yes | body hash | event |
twitch | Twitch-Eventsub-Message-Signature: HMAC-SHA256 of id + timestamp + body | yes, at least 10 min | message id | subscription.type, or revocation |
calendly | Calendly-Webhook-Signature v1: HMAC-SHA256 of t.body | yes | body hash | event |
workos | WorkOS-Signature v1: HMAC-SHA256 of t.body | yes | id | event |
typeform | Typeform-Signature: base64 HMAC-SHA256 | no | event_id | event_type |
intercom | X-Hub-Signature: HMAC-SHA1 | no | id | topic |
vercel | x-vercel-signature: HMAC-SHA1 | no | id | type |
square | x-square-hmacsha256-signature: base64 HMAC-SHA256 of notification URL + body | no | event_id | type (account: merchant_id) |
hubspot | X-HubSpot-Signature-v3: base64 HMAC-SHA256 of method + URL + body + timestamp | yes | body hash | hubspot.events |
calcom | X-Cal-Signature-256: HMAC-SHA256 | no | body hash | triggerEvent |
postmark | HTTP Basic auth; the secret is user:password | no | MessageID | RecordType |
standard, hookline | Standard Webhooks webhook-* headers (HMAC whsec_ or Ed25519 whpk_) | yes | webhook-id | type |
svix, clerk, resend | the same, with svix-* headers | yes | svix-id | type |
All comparisons are constant-time. "Body hash" means the provider sends no event id; identical bodies count as
duplicates. Slack's URL verification, Zoom's endpoint.url_validation and Twitch's callback verification are
answered automatically.
Your own presets #
import { hmacPreset, tokenPreset, basicAuthPreset } from '@hookline/inbox';
const acme = hmacPreset({
header: 'X-Acme-Signature',
algorithm: 'sha256', // 'sha1' | 'sha256' | 'sha512'
encoding: 'hex', // 'hex' | 'base64'
prefix: 'sha256=',
timestampHeader: 'X-Acme-Timestamp', // optional: signs `${timestamp}.${body}` and checks freshness
id: 'json:event.id', // a header name, or "json:" + a path into the body
type: 'X-Acme-Event',
});
sources: { acme: { preset: acme, secret: process.env.ACME_SECRET! } }signedPayload: (ctx, timestamp) => [...] overrides what's signed. tokenPreset({ header }) or
tokenPreset({ query }) compares a shared token. basicAuthPreset() checks HTTP Basic credentials.
Handlers #
inbox.on('stripe', 'invoice.paid', handler);
inbox.on('stripe', ['customer.*', 'charge.refunded'], handler);
inbox.on('stripe', handler); // every event from the source
inbox.on('*', handler); // every event from every source
const off = inbox.on(...); // call off() to remove itTypes are patterns. A handler receives the event and a context:
async function handler(event, { tx, signal }) {
event.id // "evt_..."
event.source // "stripe"
event.type // "invoice.paid", or null
event.tenantId // from the source's tenant(), or null
event.dedupeKey // the provider's event id, or "sha256:..."
event.receivedAt // Date
event.headers // lower-case, credentials removed
event.payload // parsed JSON, a form as an object, or null
event.rawBody // Buffer, exactly as received
event.attempt // 1 on the first try
}When several handlers match, they run in the order they were registered, in the same attempt. If one throws,
the attempt fails and all of them run again on the retry, so make handlers safe to repeat. Events with no
matching handler are marked ignored and kept; replay them once you add one.
Handlers time out after handlerTimeoutMs (30 s); signal aborts then. A failed attempt is retried on the
source's policy, by default after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h and 8 h (about 16 hours).
After the last one the event is dead and onEvent gets { type: 'inbox.dead', eventId, source, eventType, attempts, lastError }.
Transactional handlers #
inbox.on('stripe', 'checkout.session.completed', async (event, { tx }) => {
await tx.query('INSERT INTO orders (...) VALUES (...)', [...]);
}, { transaction: true });The handler runs inside a database transaction that also marks the event processed. Its writes and the
"processed" mark commit together or not at all, so database side effects happen exactly once even across retries
and crashes. tx is the connection holding the transaction: a pg client, a mysql2 connection, or the
node:sqlite database. Keep these handlers short; the transaction stays open while they run. Calls to other
services inside them are still at least once.
Mounting #
| Adapter | For |
|---|---|
nodeHandler(inbox, source, { trustProxy?, maxBodyBytes? }) | Node http, Express, Connect |
fetchHandler(inbox, source) | Next.js route handlers, Hono, Remix, SvelteKit, Bun, Deno |
nodeRouter(inbox, { prefix?, trustProxy? }) | one POST <prefix>/:source route for every source; prefix defaults to /webhooks |
inbox.receive(source, { method, url, headers, body }) | anything else; returns { status, headers, body } |
Signatures cover the exact bytes received, so the handler needs the raw body. Mount it before any JSON body
parser, or give the route express.raw({ type: '*/*' }). If a parser already turned the body into an object, the
adapter answers 500 with body_already_parsed and says how to fix it. trustProxy (on by default) builds the
request URL from X-Forwarded-Proto and X-Forwarded-Host.
// app/api/webhooks/[source]/route.ts (Next.js)
export async function POST(request: Request, { params }: { params: { source: string } }) {
return fetchHandler(inbox, params.source)(request);
}Running handlers #
hooks.worker({ extensions: [inbox.extension({ concurrency?, leaseMs? })] }) | alongside outbox delivery; concurrency defaults to 16 |
inbox.worker(options) | a worker that runs only the inbox |
inbox.process({ maxMs?, concurrency? }) | everything due, once; returns { processed, failed } |
Inspecting and replaying #
| Method | |
|---|---|
inbox.admin.list({ source?, status?, eventType?, tenantId?, since?, until?, limit?, cursor? }) | statuses: pending, processing, processed, ignored, dead |
inbox.admin.get(id) | one event, with duplicates and lastDuplicateAt |
inbox.admin.attempts(eventId) | every handler attempt and its error |
inbox.admin.rejections({ source? }) | rejected requests: reason, address, user agent. Never bodies. Up to rejectionLogPerMinute (60) per source per minute. |
inbox.admin.retry(id) | run one event again |
inbox.admin.replay({ source, since, until?, statuses? }) | run a source's events again, by time range; defaults to dead, ignored and processed |
inbox.admin.prune(olderThanMs) | delete finished events, attempts and rejections older than this |
Keep inbox history longer than any provider's retry window (Stripe retries for up to 3 days). Once an event is pruned its dedupe key is gone, and a late retry would be accepted as new.
For your customers: verifying webhooks
Hookline signs webhooks with the Standard Webhooks scheme, so any Standard Webhooks library verifies them. You can adapt this page for your own developer docs.
Headers #
| Header | |
|---|---|
webhook-id | Unique per message and the same on every retry. Store it and ignore repeats. |
webhook-timestamp | Seconds since the epoch when the request was signed. |
webhook-signature | One or more signatures separated by spaces, each version,base64. |
Checking the signature #
- Take the raw request body, before any JSON parsing.
- Build the signed content:
${webhook-id}.${webhook-timestamp}.${body}. - Your secret looks like
whsec_<base64>. Base64-decode the part afterwhsec_; that's the HMAC key. - Compute HMAC-SHA256 of the signed content and base64-encode it.
- Compare it, in constant time, with each
v1,entry inwebhook-signature. Any match is valid. - Reject timestamps more than 5 minutes from your clock, to stop replays.
During a secret rotation the header carries two signatures; one matches the old secret and one the new, so either works.
Node.js #
import { verify } from '@hookline/core/verify';
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
verify(req.body, req.headers, process.env.WEBHOOK_SECRET!);
} catch {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// ...
res.sendStatus(204);
});verify(body, headers, secretOrSecrets, { toleranceSeconds = 300 }) returns { id, timestamp } or throws
WebhookVerificationError with a reason: missing_headers, bad_timestamp, timestamp_too_old,
timestamp_too_new, no_matching_signature or bad_secret. headers may be a plain object or a Headers.
Python #
import base64, hashlib, hmac, time
def verify(body: bytes, headers, secret: str, tolerance=300):
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > tolerance:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
for sig in sigs.split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return
raise ValueError("no matching signature")Ed25519 endpoints #
Endpoints set up with Ed25519 signing send v1a,<base64> signatures, and you're given a public key
(whpk_<base64>) instead of a secret. Verify the signature over the same signed content with any Ed25519
library. verify() above accepts whpk_ keys directly.
Answering #
- Answer with any 2xx status within 15 seconds. Do slow work after answering.
- Anything else is retried: after 5 seconds, 5 minutes, 30 minutes, 2, 5, 10 and 10 hours.
- Answering
410 Goneturns the endpoint off. - To slow retries down, answer
429or503with aRetry-Afterheader. - Redirects are not followed; give the final URL.
Customer portal
The portal lets your customers manage their own endpoints: add and edit them, see every delivery and response, retry failures, send test events and rotate secrets. You mount an API in your app and drop a web component into your settings page.
npm i @hookline/portalServer #
import { createPortal, createPortalToken, portalNodeHandler } from '@hookline/portal';
const portal = createPortal({
hookline: hooks,
secret: process.env.HOOKLINE_PORTAL_SECRET!, // npx hookline portal-secret
title: 'Acme webhooks',
});
app.use('/hookline', portalNodeHandler(portal));
app.get('/settings/webhooks', requireLogin, (req, res) => {
const token = createPortalToken(process.env.HOOKLINE_PORTAL_SECRET!, { tenantId: req.user.accountId });
res.render('webhooks', { token });
});createPortal(options) #
| Option | Default | |
|---|---|---|
hookline | required | your createHookline() instance |
secret | required | at least 32 bytes. An array is accepted: the first signs, all verify, so you can rotate. |
editableFields | url, description, eventTypes, filter, headers, auth, version, payloadMode | endpoint fields customers may set. Also allowed: rateLimit, transform, batch, signing. Anything else is refused with field_not_editable. |
maxEndpointsPerTenant | 20 | |
corsOrigins | none | origins allowed to call the API from another origin |
title | "Webhooks" | shown at the top of the portal |
createPortalToken(secret, options) #
| Option | Default | |
|---|---|---|
tenantId | required | the only tenant this token can see |
scopes | ['read', 'write', 'secrets'] | read views; write creates, edits, deletes, tests, retries; secrets reveals and rotates signing secrets |
expiresInMs | 1 hour | at most 7 days |
endpointIds | all | limit the token to these endpoints; it can't create new ones |
Tokens look like hlp_<payload>.<signature>: base64url JSON claims and an HMAC-SHA256 over them. Mint them on your
server for a signed-in user, never in the browser. verifyPortalToken(token, secrets) returns the claims or null.
Adapters #
portalNodeHandler(portal, { maxBodyBytes? }) | Node http, Express (app.use(prefix, ...) strips the prefix for you). Bodies up to 64 KB. Works before or after express.json(). |
portalFetchHandler(portal, basePath) | Fetch API frameworks. Export it for GET, POST, PATCH, DELETE and OPTIONS. |
portal.handle({ method, path, query, headers, body }) | anything else; path is relative to the mount point |
Browser #
<hookline-portal src="/hookline" token="hlp_..."></hookline-portal>
<script src="/hookline/portal.js" defer></script>The script is served by the portal handler; there's nothing to install on the front end. It's a plain custom element with a shadow root, so it works in plain HTML, React, Vue, Svelte or anything else.
| Attribute or property | |
|---|---|
src | where the API is mounted |
token | a portal token; you can change it at any time |
theme | light or dark; follows the system setting when absent |
getToken (property) | async () => string. Called when the session expires; the request is retried with the new token. |
const el = document.querySelector('hookline-portal');
el.getToken = async () => (await fetch('/settings/webhooks/token')).text();
el.addEventListener('hookline:error', (e) => console.warn(e.detail)); // { status, error, message }Styling #
The component inherits the page's font and these custom properties (set them on the element or an ancestor):
| Property | Light default | Used for |
|---|---|---|
--hl-font | inherit | all text |
--hl-accent | #1f6feb | buttons, links, focus rings |
--hl-bg | #ffffff | background |
--hl-fg | #1f2328 | text |
--hl-muted | #636c76 | secondary text |
--hl-border | #d8dee4 | borders |
--hl-surface | #f6f8fa | subtle fills |
--hl-success, --hl-warning, --hl-danger | green, amber, red | status |
--hl-radius | 8px | corners |
The outer container is exposed as ::part(container).
API #
All routes are relative to the mount point and take Authorization: Bearer <token>. Bodies are JSON. Lists take
limit (1β100, default 25) and cursor, and return { data, nextCursor }.
| Route | Scope | |
|---|---|---|
GET /portal.js | none | the component |
GET /session | any | tenant, scopes, expiry, title, editable fields, payload versions, endpoint limit |
GET /event-types | read | the event catalog |
GET /endpoints | read | |
POST /endpoints | write | creates; the signing secret is included only with the secrets scope |
GET /endpoints/:id | read | |
PATCH /endpoints/:id | write | editable fields only |
DELETE /endpoints/:id | write | |
POST /endpoints/:id/enable, /disable | write | |
POST /endpoints/:id/test | write | body { type? } |
POST /endpoints/:id/verify | write | |
POST /endpoints/:id/recover | write | body { since? }, within the last 30 days; defaults to 24 hours ago |
GET /endpoints/:id/secret | secrets | |
POST /endpoints/:id/rotate-secret | secrets | body { overlapMs? }, at most 7 days |
GET /messages | read | ?eventType= |
GET /messages/:id | read | includes the body |
POST /messages/:id/replay | write | body { endpointId? } |
GET /deliveries | read | ?endpointId=&status=&messageId= |
GET /deliveries/:id/attempts | read | |
POST /deliveries/:id/retry | write | |
GET /stats | read | ?endpointId=&hours= (1β720, default 24) |
Every lookup checks the token's tenant. Another tenant's endpoint, message or delivery answers 404, exactly like one that doesn't exist, so ids can't be probed.
| Status | error | |
|---|---|---|
| 400 | bad_json, field_not_editable, bad_status, bad_since, endpoint_required, or a Hookline error code | the request was wrong; message says how |
| 401 | unauthorized | missing, invalid or expired token |
| 403 | forbidden | the token lacks the scope |
| 404 | not_found | |
| 409 | too_many_endpoints | |
| 415 | unsupported_media_type | a body that isn't JSON |
| 500 | internal |
Storage
Hookline keeps everything in your database. Pick the adapter for yours.
| Package | Claims | Wake-ups | |
|---|---|---|---|
| Postgres 12+ | @hookline/postgres | FOR UPDATE SKIP LOCKED | instant, across processes (LISTEN/NOTIFY) |
| MySQL 8 | @hookline/mysql | FOR UPDATE SKIP LOCKED | instant in the sending process; others poll |
| SQLite | @hookline/sqlite | BEGIN IMMEDIATE (one writer at a time) | instant in-process; others poll |
Postgres is the recommendation. MySQL suits teams already on it. SQLite suits development, tests and apps that run on one server.
Postgres #
import pg from 'pg';
import { postgresStorage } from '@hookline/postgres';
const storage = postgresStorage({ pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }) });| Option | Default | |
|---|---|---|
pool | required | a pg Pool |
schema | hookline | where the tables go; a lowercase identifier |
listen | true | keep one pooled connection on LISTEN. Turn it off behind PgBouncer in transaction mode, or in serverless functions. |
With listen on, one connection is held for as long as the instance is open. Call hooks.close() before
pool.end(), or pool.end() waits forever.
MySQL #
import mysql from 'mysql2/promise';
import { mysqlStorage } from '@hookline/mysql';
const pool = mysql.createPool(process.env.DATABASE_URL!);
const hooks = createHookline({ storage: mysqlStorage({ pool }) });
const conn = await pool.getConnection();
await conn.beginTransaction();
await conn.query('INSERT INTO orders ...');
await hooks.send(conn, event);
await conn.commit();
conn.release();Options: pool (a mysql2/promise pool) and prefix (table name prefix, default hookline_). Requires MySQL 8.0
or newer for SKIP LOCKED, window functions and CTEs. Tables use InnoDB and utf8mb4_bin.
SQLite #
import { DatabaseSync } from 'node:sqlite';
import { sqliteStorage } from '@hookline/sqlite';
const db = new DatabaseSync('app.db');
db.exec('PRAGMA journal_mode = WAL');
const hooks = createHookline({ storage: sqliteStorage({ db }) });
db.exec('BEGIN');
db.prepare('INSERT INTO orders ...').run();
await hooks.send(db, event);
db.exec('COMMIT');Uses Node's built-in node:sqlite (Node 22.13 or newer). Options: db and prefix. Use WAL mode so readers
don't block. Writes that coordinate work are serialised, which is right for one server and fine for a few
processes sharing a file.
ORMs #
send() needs your transaction. With an ORM, wrap its transaction object (Postgres):
import { fromKnex, fromKysely, fromDrizzle, fromPrisma } from '@hookline/postgres';
await knex.transaction(async (trx) => { /* ... */ await hooks.send(fromKnex(trx), event); });
import { CompiledQuery } from 'kysely';
await db.transaction().execute(async (trx) => { await hooks.send(fromKysely(trx, CompiledQuery), event); });
import { sql } from 'drizzle-orm';
await drizzleDb.transaction(async (tx) => { await hooks.send(fromDrizzle(tx, sql), event); });
await prisma.$transaction(async (tx) => { await hooks.send(fromPrisma(tx), event); });Migrations #
hooks.migrate() creates or upgrades the tables. It's idempotent, and concurrent calls queue behind a lock (a
Postgres advisory lock, a MySQL GET_LOCK), so every process can call it on boot. Migrations are never edited
once released; upgrades only add.
Tables #
| Table | Holds |
|---|---|
tenants | your customers |
endpoints | URLs or destinations, routing, secrets (sealed when encryption is on), limits, health |
messages | one row per send(); body is the exact text that's signed |
deliveries | one per message per endpoint, with status, attempt counts and the next attempt time |
attempts | one per attempt: status, timing, error, the start of the response |
event_types | the catalog |
inbox_events, inbox_attempts, inbox_rejections | the inbox |
IDs are prefixed ULIDs (msg_, ep_, dlv_, att_, evt_, bat_). They sort by creation time, which is how
lists page newest first. See Architecture for column-level detail.
Retention #
Nothing is deleted unless you ask. Either let workers prune (retentionMs) or call it yourself:
await hooks.admin.prune(30 * 24 * 3600_000); // { messages, deliveries, attempts }
await inbox.admin.prune(30 * 24 * 3600_000); // { events, attempts, rejections }Pruning removes attempts older than the cutoff, finished deliveries (succeeded, dead, cancelled) that
completed before it, and messages with no deliveries left. Pending work is never touched. Deletes run in batches
so they don't hold long locks.
Operations
Operational events #
const hooks = createHookline({
storage,
onEvent: async (e) => {
if (e.type === 'endpoint.disabled') await emailCustomer(e.tenantId, e.reason);
},
});type | Fields | When |
|---|---|---|
endpoint.circuit_opened | endpointId, tenantId, openUntil, consecutiveFailures | an endpoint failed too many times in a row |
endpoint.circuit_closed | endpointId, tenantId | a probe after the pause succeeded |
endpoint.disabled | endpointId, tenantId, reason | 410 Gone, or failing for autoDisableAfterMs |
delivery.dead | deliveryId, messageId, endpointId, tenantId, attempts, lastError | a delivery ran out of retries |
The inbox's onEvent gets inbox.dead. Handlers that throw are logged; they never affect delivery.
Metrics #
app.get('/metrics', async (_req, res) => res.type('text/plain').send(await hooks.metrics()));| Metric | Type | |
|---|---|---|
hookline_attempts_total{result, error_kind, destination} | counter | per process; a batch counts once per message |
hookline_attempt_duration_seconds{destination} | histogram | buckets 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30 s |
hookline_deliveries_due | gauge | due now, waiting for a worker |
hookline_deliveries_scheduled | gauge | waiting for a retry or a batch window |
hookline_deliveries_in_flight | gauge | being attempted |
hookline_deliveries_dead | gauge | out of retries |
hookline_messages_undispatched | gauge | not fanned out yet |
hookline_oldest_due_seconds | gauge | how long the oldest due delivery has waited |
Gauges are read from the database at scrape time, so they agree across processes. Alert on
hookline_oldest_due_seconds: if it climbs, workers aren't keeping up or aren't running.
Tracing #
Pass an OpenTelemetry tracer (anything with startSpan works):
import { trace } from '@opentelemetry/api';
const hooks = createHookline({ storage, tracer: trace.getTracer('hookline') });Each attempt gets a hookline.deliver span with hookline.endpoint_id, hookline.tenant_id, hookline.event_type,
hookline.batch_size, hookline.ok and http.response.status_code. Failed attempts set status ERROR.
onAttempt(info) is called after every attempt with { endpointId, tenantId, messageIds, eventType, ok, statusCode, errorKind, durationMs, destination, batchSize }, for your own logging.
Stats #
await hooks.admin.stats({ tenantId: 'acct_42', since: new Date(Date.now() - 7 * 864e5), bucketMs: 3600_000 });Returns { buckets: [{ start, attempts, succeeded, failed }], attempts, succeeded, failed, successRate, latencyMs: { p50, p95, p99 }, deliveries: { pending, delivering, succeeded, dead, cancelled } }. Filter by
tenantId or endpointId. since defaults to 24 hours ago; bucketMs defaults to about 48 buckets across the
range, at least one minute each; at most 1000 buckets.
hooks.admin.queue() returns the queue right now: { pendingDue, pendingScheduled, delivering, dead, undispatched, oldestDueAt }.
Logging #
Errors that don't stop delivery (a failed onEvent, a lost database connection in a loop) go to logger.error;
the default writes to console.error. Pass your own:
createHookline({ storage, logger: { error: (msg, meta) => log.error(meta, msg), warn: (msg, meta) => log.warn(meta, msg) } });Static egress IPs #
Customers sometimes allow-list the addresses webhooks come from. Send through a proxy with a fixed IP:
createHookline({ storage, proxy: 'http://user:[email protected]:3128' });HTTPS requests are tunnelled with CONNECT, plain HTTP uses absolute-form requests. Hookline still resolves and
checks every target itself and asks the proxy for that vetted IP, never the hostname, so the proxy can't be used
to reach your internal network. TLS is still verified end to end against the hostname.
Chaos mode #
See how your customers' endpoints, your alerts and your dashboards behave when deliveries fail, before it happens for real:
createHookline({ storage, chaos: { failureRate: 0.3, latencyMs: 400, statusCodes: [500, 503] } });Injected failures are recorded with errorKind: 'chaos' and retried like real ones. createHookline() throws
chaos_in_production when NODE_ENV is production, unless you also set allowInProduction: true.
AsyncAPI #
const doc = await hooks.asyncapi({ title: 'Acme webhooks', version: '1.0.0' });An AsyncAPI 3.0 document with one message per catalog event type: its description, JSON Schema, example, the
envelope and the webhook-* headers. Feed it to a docs generator or a client code generator. Also:
npx hookline asyncapi --title "Acme webhooks" --version 1.0.0 > asyncapi.json.
Sizing #
Each delivery costs about two database round trips in the common case (a success on a healthy endpoint), plus a
share of a claim. Run more workers, or raise concurrency, until hookline_oldest_due_seconds stays near zero
at your peak. Keep pool.max above concurrency plus a few for claims and admin calls.
CLI
npm i -D @hookline/cli
DATABASE_URL=postgres://... npx hookline statusThe CLI works with Postgres. It reads DATABASE_URL, HOOKLINE_SCHEMA (default hookline) and, to read encrypted
endpoint secrets, HOOKLINE_ENCRYPTION_KEYS as id:base64key,id:base64key (the first is primary). Every command
takes --json for machine-readable output and --limit for lists (default 30, at most 500). Exit code 0 means
success; 1 means an error, something not found, a failed verification, or nothing to retry.
Setup #
| Command | |
|---|---|
hookline migrate | create or upgrade the tables |
hookline keygen | print a new encryption key |
hookline portal-secret | print a new portal token secret |
hookline token --tenant <id> [--ttl 1h] | mint a portal token; needs HOOKLINE_PORTAL_SECRET |
Looking #
| Command | |
|---|---|
hookline status | queue depth, oldest due delivery, dead count, and the last 24 hours |
hookline tail [--tenant <id>] [--endpoint <id>] | print attempts as they happen; Ctrl-C to stop |
hookline endpoints [--tenant <id>] | list endpoints and their state |
hookline endpoint <id> | one endpoint in full |
hookline deliveries [--status dead|pending|succeeded] [--endpoint <id>] [--tenant <id>] | |
hookline attempts <deliveryId> | every attempt, with the start of each response |
hookline message <messageId> | the body and where it went |
hookline inbox [--source <name>] [--status dead|ignored|processed|pending] | received events |
Fixing #
| Command | |
|---|---|
hookline retry <deliveryId> | send it again now, with fresh retries |
hookline recover <endpointId> --since <2h|ISO date> | resend every failed delivery since then |
hookline replay <messageId> [--endpoint <id>] | send a message again |
hookline inbox-replay --source <name> --since <1d|ISO date> | run received events through handlers again |
hookline disable <endpointId> [--reason <text>] | |
hookline enable <endpointId> | also resets the circuit breaker |
hookline test <endpointId> [--type <event>] | queue a test event; a worker delivers it |
hookline verify <endpointId> | run the ownership check |
hookline prune --older-than <30d> | delete finished history; refuses anything under a day |
Exporting #
| Command | |
|---|---|
hookline asyncapi --title <t> --version <v> | AsyncAPI 3.0 JSON |
hookline metrics | Prometheus text |
Durations are a number and a unit: ms, s, m, h, d, w (90s, 15m, 2h, 7d).
Security
Signing #
Every request is signed with the endpoint's secret using Standard Webhooks:
signed content = webhook-id + "." + webhook-timestamp + "." + body
HMAC: webhook-signature: v1,base64(HMAC-SHA256(key, signed content)) key = base64-decoded part of "whsec_..."
Ed25519: webhook-signature: v1a,base64(Ed25519 signature) receivers hold "whpk_..." public keysThe timestamp is set per attempt, so a retry hours later still passes a 5-minute freshness check. The id stays the same, so receivers can drop duplicates. During a rotation several signatures are sent, space separated.
HMAC secrets are 32 random bytes. Ed25519 suits customers who'd rather not hold a shared secret: they only ever see the public key. Hookline's signatures match the Standard Webhooks reference test vector.
SSRF #
Endpoint URLs are typed in by your customers. Without protection, anyone could point a webhook at
169.254.169.254 (cloud metadata), localhost or your private network, and read the responses in their delivery
log. Hookline refuses these by default.
At delivery time every hostname is resolved, and if any address it resolves to is in a blocked range, nothing is
sent. The connection is then pinned to an address that passed, so a DNS answer can't change between the check and
the connection (DNS rebinding). Redirects are never followed. At creation, URLs with IP literals in blocked ranges,
and localhost, *.local and *.internal, are refused.
Blocked IPv4 ranges: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12,
192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24,
203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4. IPv6: ::, ::1, 100::/64, 2001::/32, 2001:db8::/32,
2002::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8. IPv4-mapped, IPv4-compatible and NAT64 (64:ff9b::/96)
addresses are judged by the IPv4 address inside.
createHookline({
storage,
ssrf: {
allowCidrs: ['10.20.0.0/16'], // allowed even though private, e.g. an internal service
denyCidrs: ['203.0.113.7/32'], // blocked on top of the built-in list
allowHosts: ['hooks.internal.example'], // connected to without the address check
allowPrivateNetworks: false, // true only for local development
},
});allowHttp: true permits plain http:// URLs. Use it only in development.
Encryption at rest #
Endpoint signing secrets and auth credentials can be encrypted in the database with AES-256-GCM:
createHookline({
storage,
encryption: { keys: [{ id: 'k2', key: process.env.HOOKLINE_KEY_K2! }, { id: 'k1', key: process.env.HOOKLINE_KEY_K1! }], primary: 'k2' },
});Each key is 32 bytes of base64 (npx hookline keygen, or generateEncryptionKey()). Values are stored as
enc:v1:<key id>:<iv>:<tag>:<ciphertext>. New values are sealed with primary; any listed key can open values it
sealed. To rotate: add a new key and make it primary. Values are re-sealed with it when they're written again: rotating an
endpoint's secret re-seals the new secret, and updating its auth re-seals that. Remove the old key only once no
stored value still uses it. Values written before encryption was turned on are read as
they are. A worker that finds a sealed value but has no matching key records a failed attempt that says so,
and doesn't crash.
Message bodies are not encrypted by Hookline; they're your application data, protected like the rest of your database.
The inbox #
Verification fails closed: a source with no secret refuses to start, and a request with no matching secret is
rejected. Comparisons are constant-time. Timestamped schemes reject requests outside toleranceSeconds.
Credentials headers are not stored, and rejected requests are logged without their bodies, rate-limited per source.
Portal tokens #
Portal tokens are HMAC-SHA256 signed, expire (1 hour by default, 7 days at most), and name one tenant. Every
portal lookup checks that tenant; other tenants' ids answer 404. Scopes separate reading, writing and secret
access. Rotate the portal secret by listing the new one first: secret: [newSecret, oldSecret].
Input limits #
Headers can't contain line breaks and can't override Hookline's own. Filters have no regular expressions and are capped at 100 rules. Transforms refuse prototype keys. Payloads are capped at 1 MiB by default, inbox bodies at 1 MiB, portal bodies at 64 KB, stored responses at 4 KB.
Configuration
createHookline(options) #
| Option | Default | |
|---|---|---|
storage | required | postgresStorage(), mysqlStorage() or sqliteStorage() |
envelope | true | bodies are { type, timestamp, data }; false sends data alone |
maxPayloadBytes | 1,048,576 | larger send() bodies throw payload_too_large |
retryPolicy | 8 attempts over about 27 h | see Retries; endpoints can override |
timeoutMs | 15,000 | per attempt; endpoints can override |
maxResponseBytes | 4096 | response bytes kept per attempt |
circuit | { failureThreshold: 10, openMs: 30000, maxOpenMs: 1800000 } | merged with the defaults |
disableOnGone | true | disable endpoints that answer 410 |
autoDisableAfterMs | 432,000,000 (5 days) | null never auto-disables |
maxRetryAfterMs | 3,600,000 | cap on Retry-After |
allowHttp | false | allow http:// endpoints |
ssrf | {} | { allowCidrs, denyCidrs, allowHosts, allowPrivateNetworks }; see SSRF |
proxy | none | http://[user:pass@]host:port |
userAgent | Hookline/0.1.0 (+https://hookline.dev) | |
encryption | off | { keys: [{ id, key }], primary }; see Encryption |
validator | none | (schema, data, type) => void; throw to reject a send |
versions | none | see Versions |
thinFields | ['id'] | fields kept in thin payloads |
requireVerifiedEndpoints | false | deliver only to endpoints that passed verify() |
destinations | {} | { name: Destination }; see Destinations |
chaos | off | { failureRate, latencyMs, statusCodes, allowInProduction } |
onEvent | none | operational events |
onAttempt | none | called after every attempt |
tracer | none | OpenTelemetry tracer |
logger | console | { error(msg, meta), warn(msg, meta) } |
The instance #
| Member | |
|---|---|
send(tx, input) | Sending events |
migrate() | create or upgrade tables |
worker(options) | a worker; call start() and stop() |
process(options) | do everything due once |
admin | tenants, endpoints, messages, deliveries, attempts, eventTypes, stats(), queue(), prune() |
metrics() | Prometheus text |
asyncapi(info) | AsyncAPI 3.0 document |
close() | release the storage's connections |
config | the resolved configuration |
Worker options #
See Delivery: concurrency (32), perEndpoint (8), leaseMs, pollMs (1000),
dispatchBatch (200), retentionMs, pruneEveryMs, workerId, outbox, extensions. process() also takes
maxMs (25,000) and defaults concurrency to 16.
Inbox options #
See Inbox: storage, sources, maxBodyBytes (1 MiB), retryPolicy, handlerTimeoutMs (30,000),
rejectionLogPerMinute (60), onEvent, logger.
Portal options #
See Portal: hookline, secret, editableFields, maxEndpointsPerTenant (20), corsOrigins, title.
Exports #
@hookline/core also exports the building blocks, for tests and tooling: verify, signHmac, signEd25519,
signatureHeader, generateHmacSecret, generateEd25519KeyPair, generateEncryptionKey, SecretBox,
nextRetryDelay, parseRetryAfter, eventTypeMatches, evaluateFilter, validateFilter, applyTransform,
renderBody, circuitGate, circuitRecord, gcra, blockedReason, resolveSafe, postWebhook, toAsyncAPI,
Metrics, ulid, newId, idTime, and every type. @hookline/core/verify exports verify alone, for receivers.
Errors
HooklineError #
Validation and configuration problems throw HooklineError, with a stable code and a message written for a
person.
import { HooklineError } from '@hookline/core';
try {
await hooks.admin.endpoints.create({ tenantId, url });
} catch (e) {
if (e instanceof HooklineError && e.code === 'invalid_url') return res.status(400).json({ error: e.message });
throw e;
}| Code | Thrown when |
|---|---|
invalid_config | a required option is missing or malformed (no storage, a bad schema or prefix, an inbox source without a secret, an unknown preset) |
invalid_send | send() got no transaction handle, or an empty or over-long idempotency or ordering key, or a bad deliverAt |
invalid_tenant | a tenant id is empty, over 255 characters, or contains line breaks |
invalid_event_type | an event type name doesn't follow the naming rules |
invalid_event_type_pattern | an endpoint pattern is malformed, or there are more than 200 |
invalid_payload | data can't be serialised to JSON, or the validator rejected it |
payload_too_large | the body exceeds maxPayloadBytes |
send_failed | the database accepted the insert but the message can't be found (should not happen) |
invalid_url | an endpoint URL is unusable or points somewhere refused |
invalid_headers | too many headers, a bad name, a reserved name, or a multi-line value |
invalid_auth | auth has a missing field, a line break, a bad header name, or an unknown type |
invalid_rate_limit | perSecond or burst out of range |
invalid_maxInFlight, invalid_timeoutMs | out of range |
invalid_retry_policy | a policy with an unknown kind or out-of-range values |
invalid_filter | a filter rule is malformed, too large or too deep |
invalid_transform | an unknown step, a bad path, or values too large |
invalid_batch | maxSize or maxWaitMs out of range |
invalid_destination | an unregistered destination type, or its config was rejected |
invalid_version, invalid_versions | an endpoint names an unknown version; the version list has duplicates or blanks |
unknown_version | at delivery, an endpoint is pinned to a version no longer configured (recorded as a failed attempt) |
invalid_signing | signing isn't hmac or ed25519 |
invalid_secret | a supplied secret isn't 24β64 bytes of base64, or was supplied for an Ed25519 endpoint |
invalid_overlap | rotateSecret overlap outside 0β30 days |
invalid_metadata | metadata isn't an object, or is over 16 KB |
invalid_schema | an event type's schema isn't an object |
invalid_requeue, invalid_cancel | neither ids nor an endpoint were given |
invalid_stats | the range would need more than 1000 buckets |
invalid_encryption_key | a key isn't 32 bytes of base64, a key id is malformed, or primary isn't listed |
encryption_key_missing | a stored value was sealed with a key that isn't configured |
invalid_handler | inbox.on() without a function, or for an unknown source |
invalid_token | createPortalToken() without a tenant, or with an expiry out of range |
not_found | an admin method was given an id that doesn't exist |
chaos_in_production | chaos mode with NODE_ENV=production and no allowInProduction |
WebhookVerificationError #
Thrown by verify(). reason is one of missing_headers, bad_timestamp, timestamp_too_old,
timestamp_too_new, no_matching_signature, bad_secret.
Delivery failures #
Failed attempts aren't thrown; they're recorded with an errorKind. See Delivery.
HTTP errors #
The inbox answers 401 invalid_signature, 404 unknown_source, 405 method_not_allowed, 413 body_too_large,
and 500 storage_unavailable, secret_lookup_failed or tenant_lookup_failed. The portal's errors are listed in
Portal.
How Hookline works
The pipeline #
your transaction dispatcher delivery workers
βββββββββββββββββ ββββββββββ ββββββββββββββββ
INSERT messages ββNOTIFYβββΆ claim undispatched βββΆ claim due deliveries βββΆ HTTP POST βββΆ record attempt
(one row, cheap) messages (SKIP LOCKED), (SKIP LOCKED, gated by (signed, pinned (fast path: one
match endpoints, insert circuit, rate, in-flight, IP, timeout) statement)
one delivery per endpoint ordering)- send() inserts one
messagesrow on the caller's connection and firespg_notify, which Postgres only delivers if the transaction commits. Nothing else happens inside the user's transaction. - Dispatch locks a batch of undispatched, due messages, loads the enabled endpoints of their tenants,
runs the planner (event type patterns and payload filters, in core), inserts
deliveries, and marks the messages dispatched, all in one transaction. - Claim picks due deliveries plus any whose lease expired (a crashed worker), at most
perEndpointper endpoint, and locks them withSKIP LOCKEDso workers never contend. It re-checks eligibility under the lock, including ordering. Endpoints with no limits and a closed circuit need no coordination. The rest are locked and passed through the gate (max in-flight, circuit breaker, GCRA rate limit), and refused deliveries are pushed to the time the gate will open. - Attempt: build Standard Webhooks headers with a fresh timestamp, resolve and vet the host, POST, keep the first bytes of the response.
- Complete: run the decision (core) against the endpoint's current health. If nothing about the endpoint changes, one statement updates the delivery (only if this worker still holds the claim token) and logs the attempt. Otherwise it locks the endpoint row and updates circuit, failure tracking and disabling too.
Where logic lives #
@hookline/core holds every policy decision as plain functions: planner, gate, decider, retry maths,
circuit breaker, rate limiter, signing, SSRF. Storage adapters only store, lock and call those functions
inside their transactions. A MySQL or SQLite adapter implements the Storage interface and inherits all
behaviour unchanged.
Schema (contract for adapters and ports to other languages) #
All tables live in one schema (default hookline). IDs are prefixed ULIDs (msg_, ep_, dlv_, att_),
which sort by creation time and drive newest-first keyset pagination.
| Table | Purpose |
|---|---|
tenants | Your customers. Deleting one cascades to everything recorded for them. |
endpoints | URL, routing (event types, filter), signing secrets (JSON list, optionally sealed), auth, limits, retry policy, health (circuit, failing_since, disabled). |
messages | One per send. body is the exact text signed and sent (text, not jsonb, so key order never changes). |
deliveries | One per (message, endpoint). Status pending β delivering β succeeded / dead / cancelled. seq orders ordered keys; claim_token fences stale workers. |
attempts | One per HTTP attempt: timing, status, error kind, response excerpt, worker. |
event_types | Optional catalog: description, JSON Schema, example payload. |
Why these choices #
- Body stored as text.
jsonbnormalises key order and whitespace; a signature over the original string would no longer match what was sent. - Database clock. All due times come from
now()in Postgres, so app servers with clock drift agree. - Fresh timestamp per attempt. Receivers reject old timestamps (5 minutes by default), so a retry 2 hours
later must carry the time of that attempt.
webhook-idstays the same so receivers can de-duplicate. - Leases plus claim tokens. A worker that stalls past its lease loses the delivery to another worker; when it reports back, its attempt is logged but its decision is ignored.
- No redirects. Following a redirect would let a public URL bounce the request to a private address.
The inbox #
provider ββPOSTβββΆ adapter βββΆ receive(): verify ββΆ handshake? ββΆ dedupe key ββΆ INSERT ... ON CONFLICT ββΆ 200
(NOTIFY on first insert)
worker βββΆ inboxClaim (SKIP LOCKED) βββΆ handlers βββΆ inboxComplete (claim token fenced)
ββ { transaction: true }: lock event, run handlers on the same connection,
mark processed, COMMIT; any throw rolls all of it backTables: inbox_events (raw body as received, base64 if it wasn't UTF-8; unique on (source, dedupe_key);
credentials stripped from stored headers), inbox_attempts, inbox_rejections (no bodies).
Pruning deletes finished events after the retention period. Keep retention longer than any provider's retry window (Stripe retries for up to 3 days), because once an event row is gone its dedupe key is gone too.
Roadmap
Everything below is built, tested and free under the MIT licence.
- Core: transactional outbox, fan-out, filters, Standard Webhooks signing (HMAC and Ed25519), retries, dead letters, replay and recovery, circuit breaker, rate limits, max in-flight, ordering keys, scheduled sends, SSRF guard with DNS pinning, secret rotation, encryption at rest, auto-disable, operational events, workers and run-once processing, retention, admin API, event catalog.
- Inbox: 25 provider presets plus HMAC, token and basic-auth builders, handshakes, de-duplication, store-then-ack, retried handlers, transactional exactly-once handlers, replay.
- Payloads: version pinning with downgrades, thin payloads, declarative transforms, batching.
- Destinations: SQS, SNS, EventBridge, S3, Pub/Sub, Service Bus, Kafka, RabbitMQ, NATS, Redis Streams.
- Portal: tenant tokens, isolated API, embeddable web component.
- Operations: CLI, Prometheus metrics, OpenTelemetry tracing, stats, test sends, ownership verification, egress proxy, chaos mode, AsyncAPI.
- Storage: Postgres, MySQL 8, SQLite; Knex, Kysely, Drizzle and Prisma transaction helpers.
Ideas, not promises #
- SQL Server storage (needs a real SQL Server to test against).
- Ports to other languages sharing the same schema.
- Alerts for secrets that are about to stop working after a rotation.