Seal
API reference

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.

FieldTypeRequiredNotes
urlstring (URL)yesWhere deliveries are POSTed.
eventsstring[]yesAt 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

TypeFired when
approval.approveda request is approved
approval.rejecteda request is rejected
approval.expireda request times out
approval.escalateda 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"
  }
}
FieldTypeNotes
idstring (uuid)Unique event id — use it to dedupe retries.
typeenumOne of the event types above.
createdAtstring (ISO)When the event was emitted.
data.approvalIdstring (uuid)The approval this concerns.
data.statusenumApproval status at emit time.
data.verdictstring | nullapproved / rejected, or null for expiry.
data.actionstringThe approval's action.
data.payloadobjectThe approval's payload.
data.feedbackstring | nullReviewer feedback, when present.

Signature

Each delivery carries three headers:

HeaderValue
x-seal-signaturev1=<hex> where <hex> is the HMAC (see below)
x-seal-timestampUnix seconds when the delivery was signed
x-seal-webhook-idDelivery id

The signature is computed as:

signature = "v1=" + HMAC_SHA256(secret, `${timestamp}.${rawBody}`)   // hex-encoded

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

verify.js
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.

On this page