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
| Code | When |
|---|---|
VALIDATION_FAILED | The runId path parameter isn't a valid identifier. |
NOT_FOUND | No run/delivery with this id is visible to this tenant. |
AUTHENTICATION_REQUIRED / FORBIDDEN | Missing, 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.