HooklineDocs ← Hookline home

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

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 hookline command.
  • Security: signing, SSRF protection, encryption at rest, portal tokens.

Reference

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 pg

Create 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 signatures

Send 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 #

  • Let customers manage their own endpoints with the portal.
  • Receive webhooks from other services with the inbox.
  • Read Delivery for what happens when an endpoint is down.

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
});
FieldType
tenantIdstring, 1–255 charsThe customer the event belongs to. Only that tenant's endpoints receive it.
typestringEvent type, e.g. order.paid.
dataany JSON valueThe payload.
idempotencyKeystring, 1–255 charsSending twice with the same key for the same tenant stores one message. The second call returns the first message's id and duplicate: true.
orderingKeystring, 1–255 charsMessages with the same key reach each endpoint one at a time, in the order sent.
deliverAtDateDon'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) throw payload_too_large.
  • Values that can't be serialised to JSON (circular references, BigInt) throw invalid_payload.
  • send() throws invalid_send when 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:

PatternMatches
order.paidexactly 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 },
});
MethodReturns
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.

FieldDefaultLimits and notes
tenantIdrequired1–255 chars
urlrequired for HTTPhttps:// (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.
descriptionnull
eventTypesnull (everything)up to 200 patterns
filternullsee Filters
headers{}up to 32 extra headers; see Headers and auth
authnull{ type: 'bearer', token }, { type: 'basic', username, password } or { type: 'header', name, value }
signing'hmac''hmac' (HMAC-SHA256) or 'ed25519'
secretgeneratedbring your own whsec_... secret (24–64 bytes) when migrating from another system; HMAC only
rateLimitnull{ perSecond, burst }; perSecond up to 10,000; burst 1–10,000, default 1
maxInFlightnull1–1000 attempts at once
timeoutMsconfig timeoutMs1,000–120,000
retryPolicyconfig retryPolicysee Retries
metadata{}any JSON object under 16 KB, for your own use
disabledfalsecreate it switched off
destinationnull (HTTP){ type, config }; see Destinations
versionlatest configuredsee Versions
payloadMode'full''thin'; see Thin payloads
transformnullsee Transforms
batchnull{ maxSize, maxWaitMs }; see Batching

Reading and changing #

MethodReturns
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' } ] }
opTrue when
eq, neqequal, not equal (deep equality for objects and arrays)
gt, gte, lt, lteboth sides are numbers, or both strings, and the comparison holds. No type coercion.
in, ninvalue is (not) in the given array
existsthe path exists. value: false inverts it. A path holding null exists.
containsa string contains the substring, or an array contains the element
startsWith, endsWithstring 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 used

During 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 #

MethodReturns
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.

OptionDefault
concurrency32attempts in flight at once from this process
perEndpoint8most deliveries one endpoint can take in a single claim, so one busy endpoint can't take every slot
leaseMsmax(60 s, 2 Γ— timeoutMs)how long a claimed delivery stays reserved; must be longer than the timeout
pollMs1000fallback poll interval
dispatchBatch200messages fanned out per batch
retentionMsnull (keep forever)delete finished history older than this, every pruneEveryMs
pruneEveryMs600,000
workerIdhostname + randomrecorded on every attempt
outboxtruefalse 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:

HeaderValue
content-typeapplication/json
user-agentHookline/0.1.0 (+https://hookline.dev) unless you set userAgent
webhook-idthe message id; the same on every retry, so receivers can drop duplicates
webhook-timestampseconds since the epoch, taken when this attempt was signed
webhook-signaturev1,<base64> (HMAC) or v1a,<base64> (Ed25519); several, space separated, during a rotation
webhook-versionthe 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).

errorKindMeaning
httpthe endpoint answered, but not with 2xx (including 3xx redirects)
timeoutno complete response within the timeout
dnsthe hostname didn't resolve
connectionrefused, reset, unreachable, or the request couldn't be prepared
tlscertificate or handshake problem
blockedthe address is in a range the SSRF guard refuses
invalid_urlthe stored URL can't be used (wrong protocol, unparseable)
destinationa non-HTTP destination reported a failure
chaosfailure injected by chaos mode

Retries #

A failed attempt is retried on the endpoint's retryPolicy, or the config's. The default is a schedule:

Attempt12345678
Waits before it–5 s5 min30 min2 h5 h10 h10 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.

MethodEffect
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/destinations
import { 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-timestamp and webhook-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/inbox
import { 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 #

  1. Methods other than POST get 405; bodies over maxBodyBytes (1 MiB) get 413; unknown sources get 404.
  2. 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.
  3. Verification challenges (Slack, Zoom, Twitch) are answered directly, after verifying, and not stored.
  4. 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.
  5. The answer is 200 {"ok":true,"id":"evt_..."}, with "duplicate":true for repeats. If storage fails the answer is 500, 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 },
  },
}
FieldDefault
presetrequireda preset name, or a preset you build (see below)
secretrequireda string, several strings (tried in turn; that's how you rotate), or a function (req, account) => string | string[] | null, which may be async
tenantnone(req, account) => string | null; stored as the event's tenantId
publicUrlthe request URLthe URL the provider calls, for presets that sign the URL (Twilio, Square, HubSpot) when you're behind a proxy that changes host or scheme
toleranceSeconds300clock skew accepted for presets that sign a timestamp
retryPolicyinbox retryPolicyfor 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 #

PresetSignature checkedTimestamp checkedDedupe keyEvent type
stripeStripe-Signature v1: HMAC-SHA256 of t.body, keyed with the whole whsec_ stringyesidtype (account: account)
githubX-Hub-Signature-256: HMAC-SHA256noX-GitHub-DeliveryX-GitHub-Event + .action (account: installation id)
gitlabX-Gitlab-Token equals the secretnoX-Gitlab-Event-UUIDobject_kind
bitbucketX-Hub-Signature: HMAC-SHA256noX-Request-UUIDX-Event-Key
shopifyX-Shopify-Hmac-Sha256: base64 HMAC-SHA256noX-Shopify-Event-Id or X-Shopify-Webhook-IdX-Shopify-Topic (account: shop domain)
slackX-Slack-Signature: HMAC-SHA256 of v0:ts:bodyyesevent_idevent.type, else type (account: team_id)
twilioX-Twilio-Signature: base64 HMAC-SHA1 of URL + sorted form params, or of the URL with bodySHA256 for JSONnoI-Twilio-Idempotency-TokenEventType, MessageStatus, SmsStatus or CallStatus (account: AccountSid)
sendgridECDSA P-256 over timestamp + body; the secret is the verification public key (base64 DER or PEM)nobody hashsendgrid.events
mailgunHMAC-SHA256 of timestamp + token, from the body's signature object or form fieldsyesevent-data.idevent-data.event
paddlePaddle-Signature h1: HMAC-SHA256 of ts:bodyyesevent_idevent_type
lemonsqueezyX-Signature: HMAC-SHA256nobody hashX-Event-Name
linearLinear-Signature: HMAC-SHA256yes (webhookTimestamp in the body)Linear-DeliveryLinear-Event + .action
zoomx-zm-signature: HMAC-SHA256 of v0:ts:bodyyesbody hashevent
twitchTwitch-Eventsub-Message-Signature: HMAC-SHA256 of id + timestamp + bodyyes, at least 10 minmessage idsubscription.type, or revocation
calendlyCalendly-Webhook-Signature v1: HMAC-SHA256 of t.bodyyesbody hashevent
workosWorkOS-Signature v1: HMAC-SHA256 of t.bodyyesidevent
typeformTypeform-Signature: base64 HMAC-SHA256noevent_idevent_type
intercomX-Hub-Signature: HMAC-SHA1noidtopic
vercelx-vercel-signature: HMAC-SHA1noidtype
squarex-square-hmacsha256-signature: base64 HMAC-SHA256 of notification URL + bodynoevent_idtype (account: merchant_id)
hubspotX-HubSpot-Signature-v3: base64 HMAC-SHA256 of method + URL + body + timestampyesbody hashhubspot.events
calcomX-Cal-Signature-256: HMAC-SHA256nobody hashtriggerEvent
postmarkHTTP Basic auth; the secret is user:passwordnoMessageIDRecordType
standard, hooklineStandard Webhooks webhook-* headers (HMAC whsec_ or Ed25519 whpk_)yeswebhook-idtype
svix, clerk, resendthe same, with svix-* headersyessvix-idtype

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 it

Types 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 #

AdapterFor
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-idUnique per message and the same on every retry. Store it and ignore repeats.
webhook-timestampSeconds since the epoch when the request was signed.
webhook-signatureOne or more signatures separated by spaces, each version,base64.

Checking the signature #

  1. Take the raw request body, before any JSON parsing.
  2. Build the signed content: ${webhook-id}.${webhook-timestamp}.${body}.
  3. Your secret looks like whsec_<base64>. Base64-decode the part after whsec_; that's the HMAC key.
  4. Compute HMAC-SHA256 of the signed content and base64-encode it.
  5. Compare it, in constant time, with each v1, entry in webhook-signature. Any match is valid.
  6. 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 Gone turns the endpoint off.
  • To slow retries down, answer 429 or 503 with a Retry-After header.
  • 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/portal

Server #

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) #

OptionDefault
hooklinerequiredyour createHookline() instance
secretrequiredat least 32 bytes. An array is accepted: the first signs, all verify, so you can rotate.
editableFieldsurl, description, eventTypes, filter, headers, auth, version, payloadModeendpoint fields customers may set. Also allowed: rateLimit, transform, batch, signing. Anything else is refused with field_not_editable.
maxEndpointsPerTenant20
corsOriginsnoneorigins allowed to call the API from another origin
title"Webhooks"shown at the top of the portal

createPortalToken(secret, options) #

OptionDefault
tenantIdrequiredthe only tenant this token can see
scopes['read', 'write', 'secrets']read views; write creates, edits, deletes, tests, retries; secrets reveals and rotates signing secrets
expiresInMs1 hourat most 7 days
endpointIdsalllimit 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
srcwhere the API is mounted
tokena portal token; you can change it at any time
themelight 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):

PropertyLight defaultUsed for
--hl-fontinheritall text
--hl-accent#1f6febbuttons, links, focus rings
--hl-bg#ffffffbackground
--hl-fg#1f2328text
--hl-muted#636c76secondary text
--hl-border#d8dee4borders
--hl-surface#f6f8fasubtle fills
--hl-success, --hl-warning, --hl-dangergreen, amber, redstatus
--hl-radius8pxcorners

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 }.

RouteScope
GET /portal.jsnonethe component
GET /sessionanytenant, scopes, expiry, title, editable fields, payload versions, endpoint limit
GET /event-typesreadthe event catalog
GET /endpointsread
POST /endpointswritecreates; the signing secret is included only with the secrets scope
GET /endpoints/:idread
PATCH /endpoints/:idwriteeditable fields only
DELETE /endpoints/:idwrite
POST /endpoints/:id/enable, /disablewrite
POST /endpoints/:id/testwritebody { type? }
POST /endpoints/:id/verifywrite
POST /endpoints/:id/recoverwritebody { since? }, within the last 30 days; defaults to 24 hours ago
GET /endpoints/:id/secretsecrets
POST /endpoints/:id/rotate-secretsecretsbody { overlapMs? }, at most 7 days
GET /messagesread?eventType=
GET /messages/:idreadincludes the body
POST /messages/:id/replaywritebody { endpointId? }
GET /deliveriesread?endpointId=&status=&messageId=
GET /deliveries/:id/attemptsread
POST /deliveries/:id/retrywrite
GET /statsread?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.

Statuserror
400bad_json, field_not_editable, bad_status, bad_since, endpoint_required, or a Hookline error codethe request was wrong; message says how
401unauthorizedmissing, invalid or expired token
403forbiddenthe token lacks the scope
404not_found
409too_many_endpoints
415unsupported_media_typea body that isn't JSON
500internal

Storage

Hookline keeps everything in your database. Pick the adapter for yours.

PackageClaimsWake-ups
Postgres 12+@hookline/postgresFOR UPDATE SKIP LOCKEDinstant, across processes (LISTEN/NOTIFY)
MySQL 8@hookline/mysqlFOR UPDATE SKIP LOCKEDinstant in the sending process; others poll
SQLite@hookline/sqliteBEGIN 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 }) });
OptionDefault
poolrequireda pg Pool
schemahooklinewhere the tables go; a lowercase identifier
listentruekeep 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 #

TableHolds
tenantsyour customers
endpointsURLs or destinations, routing, secrets (sealed when encryption is on), limits, health
messagesone row per send(); body is the exact text that's signed
deliveriesone per message per endpoint, with status, attempt counts and the next attempt time
attemptsone per attempt: status, timing, error, the start of the response
event_typesthe catalog
inbox_events, inbox_attempts, inbox_rejectionsthe 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);
  },
});
typeFieldsWhen
endpoint.circuit_openedendpointId, tenantId, openUntil, consecutiveFailuresan endpoint failed too many times in a row
endpoint.circuit_closedendpointId, tenantIda probe after the pause succeeded
endpoint.disabledendpointId, tenantId, reason410 Gone, or failing for autoDisableAfterMs
delivery.deaddeliveryId, messageId, endpointId, tenantId, attempts, lastErrora 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()));
MetricType
hookline_attempts_total{result, error_kind, destination}counterper process; a batch counts once per message
hookline_attempt_duration_seconds{destination}histogrambuckets 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30 s
hookline_deliveries_duegaugedue now, waiting for a worker
hookline_deliveries_scheduledgaugewaiting for a retry or a batch window
hookline_deliveries_in_flightgaugebeing attempted
hookline_deliveries_deadgaugeout of retries
hookline_messages_undispatchedgaugenot fanned out yet
hookline_oldest_due_secondsgaugehow 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 status

The 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 migratecreate or upgrade the tables
hookline keygenprint a new encryption key
hookline portal-secretprint a new portal token secret
hookline token --tenant <id> [--ttl 1h]mint a portal token; needs HOOKLINE_PORTAL_SECRET

Looking #

Command
hookline statusqueue 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 metricsPrometheus 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 keys

The 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) #

OptionDefault
storagerequiredpostgresStorage(), mysqlStorage() or sqliteStorage()
envelopetruebodies are { type, timestamp, data }; false sends data alone
maxPayloadBytes1,048,576larger send() bodies throw payload_too_large
retryPolicy8 attempts over about 27 hsee Retries; endpoints can override
timeoutMs15,000per attempt; endpoints can override
maxResponseBytes4096response bytes kept per attempt
circuit{ failureThreshold: 10, openMs: 30000, maxOpenMs: 1800000 }merged with the defaults
disableOnGonetruedisable endpoints that answer 410
autoDisableAfterMs432,000,000 (5 days)null never auto-disables
maxRetryAfterMs3,600,000cap on Retry-After
allowHttpfalseallow http:// endpoints
ssrf{}{ allowCidrs, denyCidrs, allowHosts, allowPrivateNetworks }; see SSRF
proxynonehttp://[user:pass@]host:port
userAgentHookline/0.1.0 (+https://hookline.dev)
encryptionoff{ keys: [{ id, key }], primary }; see Encryption
validatornone(schema, data, type) => void; throw to reject a send
versionsnonesee Versions
thinFields['id']fields kept in thin payloads
requireVerifiedEndpointsfalsedeliver only to endpoints that passed verify()
destinations{}{ name: Destination }; see Destinations
chaosoff{ failureRate, latencyMs, statusCodes, allowInProduction }
onEventnoneoperational events
onAttemptnonecalled after every attempt
tracernoneOpenTelemetry tracer
loggerconsole{ 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
admintenants, endpoints, messages, deliveries, attempts, eventTypes, stats(), queue(), prune()
metrics()Prometheus text
asyncapi(info)AsyncAPI 3.0 document
close()release the storage's connections
configthe 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;
}
CodeThrown when
invalid_configa required option is missing or malformed (no storage, a bad schema or prefix, an inbox source without a secret, an unknown preset)
invalid_sendsend() got no transaction handle, or an empty or over-long idempotency or ordering key, or a bad deliverAt
invalid_tenanta tenant id is empty, over 255 characters, or contains line breaks
invalid_event_typean event type name doesn't follow the naming rules
invalid_event_type_patternan endpoint pattern is malformed, or there are more than 200
invalid_payloaddata can't be serialised to JSON, or the validator rejected it
payload_too_largethe body exceeds maxPayloadBytes
send_failedthe database accepted the insert but the message can't be found (should not happen)
invalid_urlan endpoint URL is unusable or points somewhere refused
invalid_headerstoo many headers, a bad name, a reserved name, or a multi-line value
invalid_authauth has a missing field, a line break, a bad header name, or an unknown type
invalid_rate_limitperSecond or burst out of range
invalid_maxInFlight, invalid_timeoutMsout of range
invalid_retry_policya policy with an unknown kind or out-of-range values
invalid_filtera filter rule is malformed, too large or too deep
invalid_transforman unknown step, a bad path, or values too large
invalid_batchmaxSize or maxWaitMs out of range
invalid_destinationan unregistered destination type, or its config was rejected
invalid_version, invalid_versionsan endpoint names an unknown version; the version list has duplicates or blanks
unknown_versionat delivery, an endpoint is pinned to a version no longer configured (recorded as a failed attempt)
invalid_signingsigning isn't hmac or ed25519
invalid_secreta supplied secret isn't 24–64 bytes of base64, or was supplied for an Ed25519 endpoint
invalid_overlaprotateSecret overlap outside 0–30 days
invalid_metadatametadata isn't an object, or is over 16 KB
invalid_schemaan event type's schema isn't an object
invalid_requeue, invalid_cancelneither ids nor an endpoint were given
invalid_statsthe range would need more than 1000 buckets
invalid_encryption_keya key isn't 32 bytes of base64, a key id is malformed, or primary isn't listed
encryption_key_missinga stored value was sealed with a key that isn't configured
invalid_handlerinbox.on() without a function, or for an unknown source
invalid_tokencreatePortalToken() without a tenant, or with an expiry out of range
not_foundan admin method was given an id that doesn't exist
chaos_in_productionchaos 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)
  1. send() inserts one messages row on the caller's connection and fires pg_notify, which Postgres only delivers if the transaction commits. Nothing else happens inside the user's transaction.
  2. 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.
  3. Claim picks due deliveries plus any whose lease expired (a crashed worker), at most perEndpoint per endpoint, and locks them with SKIP LOCKED so 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.
  4. Attempt: build Standard Webhooks headers with a fresh timestamp, resolve and vet the host, POST, keep the first bytes of the response.
  5. 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.

TablePurpose
tenantsYour customers. Deleting one cascades to everything recorded for them.
endpointsURL, routing (event types, filter), signing secrets (JSON list, optionally sealed), auth, limits, retry policy, health (circuit, failing_since, disabled).
messagesOne per send. body is the exact text signed and sent (text, not jsonb, so key order never changes).
deliveriesOne per (message, endpoint). Status pending β†’ delivering β†’ succeeded / dead / cancelled. seq orders ordered keys; claim_token fences stale workers.
attemptsOne per HTTP attempt: timing, status, error kind, response excerpt, worker.
event_typesOptional catalog: description, JSON Schema, example payload.

Why these choices #

  • Body stored as text. jsonb normalises 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-id stays 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 back

Tables: 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.