Webhooks
Manage webhook endpoints and verify the signed decision webhook — payload shape, headers, and the HMAC signature scheme.
Seal POSTs a signed webhook to your registered endpoints when an approval reaches a
terminal state. Deliveries are retried on failure. Verify every delivery before trusting it —
the verifyWebhook() helper does this for you.
Managing endpoints
Register the URLs Seal delivers to and the events each should receive. Endpoints are
org-scoped, and the signing secret is returned once at creation — store it, you cannot
read it back later.
POST /v1/webhooks
Registers an endpoint. Requires the webhooks:write scope.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string (URL) | yes | Where deliveries are POSTed. |
events | string[] | yes | At least one of the event types below. |
curl -X POST https://api.your-org.com/v1/webhooks \
-H "authorization: Bearer sk_live_..." \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/hooks/seal",
"events": ["approval.approved", "approval.rejected"]
}'Returns 201 with the endpoint and its one-time secret:
{
"id": "a1b2…-uuid",
"url": "https://example.com/hooks/seal",
"events": ["approval.approved", "approval.rejected"],
"enabled": true,
"createdAt": "2026-07-05T12:00:00.000Z",
"secret": "whsec_…"
}GET /v1/webhooks
Lists your endpoints, without secrets. Requires the webhooks:read scope.
{
"endpoints": [
{
"id": "a1b2…-uuid",
"url": "https://example.com/hooks/seal",
"events": ["approval.approved", "approval.rejected"],
"enabled": true,
"createdAt": "2026-07-05T12:00:00.000Z"
}
]
}DELETE /v1/webhooks/:id
Removes an endpoint. Requires the webhooks:write scope. Returns 204 on success, or 404
if no endpoint with that id exists in your org.
Event types
| Type | Fired when |
|---|---|
approval.approved | a request is approved |
approval.rejected | a request is rejected |
approval.expired | a request times out |
approval.escalated | a request escalates to the next step |
Payload
{
"id": "b2a1…-uuid",
"type": "approval.rejected",
"createdAt": "2026-07-05T12:03:00.000Z",
"data": {
"approvalId": "6f1c…-uuid",
"status": "rejected",
"verdict": "rejected",
"action": "wire.transfer",
"payload": { "to": "acct_999", "amount": 5000 },
"feedback": "Vendor not on the approved list"
}
}| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Unique event id — use it to dedupe retries. |
type | enum | One of the event types above. |
createdAt | string (ISO) | When the event was emitted. |
data.approvalId | string (uuid) | The approval this concerns. |
data.status | enum | Approval status at emit time. |
data.verdict | string | null | approved / rejected, or null for expiry. |
data.action | string | The approval's action. |
data.payload | object | The approval's payload. |
data.feedback | string | null | Reviewer feedback, when present. |
Signature
Each delivery carries three headers:
| Header | Value |
|---|---|
x-seal-signature | v1=<hex> where <hex> is the HMAC (see below) |
x-seal-timestamp | Unix seconds when the delivery was signed |
x-seal-webhook-id | Delivery id |
The signature is computed as:
signature = "v1=" + HMAC_SHA256(secret, `${timestamp}.${rawBody}`) // hex-encodedTo verify: recompute the HMAC over `${timestamp}.${rawBody}` with your endpoint's
secret, compare in constant time to the header value, and reject if the timestamp is more
than 300 seconds (default tolerance) from now.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, timestamp, rawBody, signature, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(now - ts) > toleranceSeconds) return false;
const expected =
"v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}Sign over the raw request bytes, before any JSON parse/re-serialize. Re-encoding changes the bytes and breaks the signature.
In Node, prefer the SDK: verifyWebhook(rawBody, headers, secret)
does the timestamp check, constant-time compare, and schema validation, and hands you a typed
event.