Skip to main content

Verify a webhook

Rule-line delivers exactly two terminal events — run.succeeded and run.failed — to your registered destination URL as soon as a run terminates. Queued/active state is never delivered; poll for that (see Poll a run). See the full webhook event reference for the complete event body field list.

What you receive​

Every delivery attempt carries a Rule-Line-Signature header and the exact JSON event body, byte for byte:

POST /webhooks/rule-line/incoming-terminal-events HTTP/1.1
Host: integrations.example-customer-tenant.example.com
Content-Type: application/json
Rule-Line-Signature: t=1780012345,v1=c1a2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90

{"eventId":"018f2c1a-9e3b-7c9a-8b0e-4f2a6d1c5e7b","runId":"22222222-2222-4222-8222-222222222222","eventType":"run.succeeded","eventCreatedAt":"2026-09-14T12:00:04.000Z","runCompletedAt":"2026-09-14T12:00:04.000Z","rulesetId":"11111111-1111-4111-8111-111111111111","rulesetVersion":3,"test":false,"outcome":"succeeded","result":{"approved":true,"matchedCreditorId":"acme-gmbh"}}

Verifying the signature​

Compute the HMAC over the raw bytes your HTTP server received — never a re-serialized copy of the parsed JSON. Read the request body as a Buffer before any JSON parsing touches it; parsing and re-stringifying can silently reorder keys or change whitespace, which would make a correct signature fail to verify.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyRuleLineWebhookSignature(
rawBody: Buffer,
headerValue: string,
secrets: { active: string; previous?: string },
options: { toleranceSeconds?: number; now?: number } = {},
): boolean {
const toleranceSeconds = options.toleranceSeconds ?? 300;
const nowSeconds = options.now ?? Math.floor(Date.now() / 1000);

let timestamp: number | null = null;
const signatures: string[] = [];
for (const part of headerValue.split(',')) {
const [key, value] = part.split('=');
if (key === 't') timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;

const candidates = secrets.previous ? [secrets.active, secrets.previous] : [secrets.active];
for (const signature of signatures) {
for (const secret of candidates) {
const expected = createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
.digest('hex');
if (signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return true;
}
}
}
return false;
}

Call it with the two secrets your destination currently holds (secret at creation, or the pair returned by rotation — see below):

const isValid = verifyRuleLineWebhookSignature(rawBodyBuffer, signatureHeader, {
active: process.env.RULE_LINE_WEBHOOK_SECRET_ACTIVE!,
previous: process.env.RULE_LINE_WEBHOOK_SECRET_PREVIOUS,
});

Rotation overlap​

During a secret rotation's overlap window, the header carries two v1 values — the current (active) secret's signature always first, then the previous secret's: t=<unix>,v1=<activeHex>,v1=<previousHex>. Accept a match against any v1 value present, not only the first — the function above already loops over every v1 against every held secret.

Replay tolerance​

A signature whose timestamp is more than 300 seconds away from your server's current time — in either direction — is rejected. This bounds both a stale replayed delivery and a forged header claiming a future timestamp.

Acknowledging delivery​

Any HTTP response in the 2xx range acknowledges the delivery — return one as soon as you've durably recorded the event, before doing any slow downstream work. A 3xx, 4xx, 5xx, transport error, or timeout is all treated as unsuccessful and retried, including a 4xx — a delivery cycle makes up to 10 attempts over roughly 72 hours (see the retry schedule).

Deduplicating by eventId​

eventId is the same value across every automatic and manual delivery attempt for one terminal event — never the run id. Persist eventId (with a uniqueness constraint) before taking any side-effecting action, and skip processing if you've already recorded it. A retried delivery of the same event will arrive with an unchanged body and a new sentAt, but the identical eventId.

Cross-referencing delivery detail​

You can look up a run's own delivery/attempt history at any time — useful for reconciling what you received against rule-line's own record, or for checking whether a delivery has exhausted its attempts and can be manually retried (see Recover a delivery):

import { client, getRunDelivery } from '@one-unity/rule-line-sdk';

client.setConfig({
baseUrl: 'https://api.rule-line.example.com',
auth: 'rl_key_your_api_key',
});

const result = await getRunDelivery({ path: { runId: '22222222-2222-4222-8222-222222222222' } });
if (result.error) {
throw new Error(`delivery lookup failed: ${result.error.code}`);
}
console.log(`eventId ${result.data.eventId}, canRetry: ${result.data.canRetry}`);
GET /runs/22222222-2222-4222-8222-222222222222/delivery HTTP/1.1
Host: api.rule-line.example.com
Authorization: Bearer rl_key_your_api_key
{
"runId": "22222222-2222-4222-8222-222222222222",
"eventId": "018f2c1a-9e3b-7c9a-8b0e-4f2a6d1c5e7b",
"eventType": "run.succeeded",
"test": false,
"eventCreatedAt": "2026-09-14T12:00:04.000Z",
"runCompletedAt": "2026-09-14T12:00:04.000Z",
"hasDestination": true,
"canRetry": false,
"attempts": [
{
"cycleNumber": 1,
"attemptNumber": 1,
"attemptLabel": "1 of 10",
"status": "succeeded",
"sentAt": "2026-09-14T12:00:05.000Z",
"destinationUrlUsed": "https://integrations.example-customer-tenant.example.com/webhooks/rule-line/incoming-terminal-events",
"secretVersionUsed": "55555555-5555-4555-8555-555555555555",
"httpStatus": 200,
"latencyMs": 142,
"transportError": null,
"nextAttemptAt": null,
"requestPreview": null,
"responsePreview": null
}
]
}

This route requires only the read scope, unlike manual retry (see Recover a delivery, which requires a signed-in tenant session with an editor or approver role).

Errors​

CodeWhen
VALIDATION_FAILEDThe runId path parameter isn't a valid identifier.
NOT_FOUNDNo run/delivery with this id is visible to this tenant.
AUTHENTICATION_REQUIRED / FORBIDDENMissing, invalid, or under-scoped credentials.

See the full GET /runs/{runId}/delivery reference for the complete schema, and the webhook event reference for the signing grammar, event body, and retry schedule in full.