Aller au contenu
Docs

Webhooks

Push instead of poll — signed events at your endpoint when a change is detected or a rule raises an alert. Signing, verification, retries and rotation, in full.

Ce document n'est publié qu'en anglais. Une traduction devrait être une traduction juridique, et nous préférons vous montrer le texte qui fait foi plutôt qu'une approximation.

The two events at the heart of the product are the graph's own: change_event.detected is the firehose for your graph, and alert.created is only what your rules decided mattered. Four more cover the things a compliance platform syncs on rather than polls for — an inventory change, a finished artifact, a signed attestation. An endpoint subscribes to any of them, or to *. Endpoints are managed at Settings, Integrations — or over the API.

The event types

Type
Fires when
change_event.detected
A change was detected beneath a vendor you watch. The firehose; <code>data</code> carries the change as it crossed the graph boundary.
alert.created
A rule matched a change and raised an alert. Only what your rules decided mattered.
vendor.watched
A vendor was added to the inventory — from the console, the API, an import or the onboarding wizard. Watching one twice does not fire it again.
vendor.unwatched
A vendor was removed. The alerts it produced stay; un-watching does not un-happen them.
export.completed
A requested export finished and its file is ready. Never on a failure — a failed export is a row you poll, not an event. The bytes are not in the envelope; fetch them from <code>download_path</code> with your own credentials.
attestation.issued
A signed monitoring attestation was issued for a closed month. The signed document travels verbatim under <code>data.document</code>, so it can be verified straight out of the delivery.

The envelope

Every delivery is one JSON object, Stripe-shaped. The data half carries codes and ids, never display strings — your parser keeps working when a ninth language ships; the localized sentence travels alongside, marked with its locale.

{
  "id": "evt_8Kq2mYb4Rn0pWc7xTd1sVg",
  "type": "alert.created",
  "api_version": "2026-09-01",
  "created": "2026-08-18T06:12:04Z",
  "livemode": true,
  "account_id": 204,
  "data": { ... }
}

id is the event id: every delivery of one event carries it — automatic retries, a replay from Settings, Integrations, and the copies sent to your other endpoints. Deduplicate on it. created is when the event happened, in UTC ISO 8601, like every timestamp in the API. Every id in data is a public id you can hand back to the API — for alert.created, data.rule.id is the rule's rule_... id, the one /api/v1/rules/{public_id} takes.

api_version is a date, not the API's /v1. It is pinned when the endpoint is created and freezes the envelope shape that endpoint receives — an event pushed to you cannot carry a version in its path the way a request you make can. The two version schemes move independently.

Headers and verification

Deliveries are signed in the Standard Webhooks style: HMAC-SHA256 over id.timestamp.payload, base64-encoded, prefixed v1,. The delivery id and timestamp are inside the signed string, so neither can be swapped without breaking the signature.

Header
Carries
webhook-id
The delivery id (whd_...), which the signature covers. Automatic retries of a delivery reuse it; a replay from the settings screen is a new delivery with a new one. Not a dedupe key — deduplicate on the envelope <code>id</code>, which a replay keeps.
webhook-timestamp
Unix seconds when the delivery was signed. Reject stale timestamps to bound replay.
webhook-signature
One or more signatures, space-separated. During a secret rotation both secrets sign, so at least one must verify.
nthparty-event-type
The event type, duplicated from the envelope for cheap routing.

Verify before parsing. The secret is the whsec_... value shown once when the endpoint was created or its secret rotated; the base64 part after the prefix is the key.

# Ruby
require "openssl"
require "base64"

def verified?(secret, id, timestamp, body, header)
  key = Base64.decode64(secret.delete_prefix("whsec_"))
  mac = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")
  expected = "v1,#{Base64.strict_encode64(mac)}"
  header.split(" ").any? do |candidate|
    OpenSSL.secure_compare(candidate, expected)
  end
end
// Node
const crypto = require("crypto");

function verified(secret, id, timestamp, body, header) {
  const key = Buffer.from(secret.replace("whsec_", ""), "base64");
  const mac = crypto.createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`).digest("base64");
  const expected = `v1,${mac}`;
  return header.split(" ").some((candidate) =>
    candidate.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));
}

Retries and endpoint health

Answer 2xx within 15 seconds; anything else is a failure. A failed delivery is retried up to 8 attempts over roughly 28 hours — immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and twice more after 10 hours. An endpoint that fails 15 deliveries in a row, or fails continuously for 5 days, is disabled automatically and the integrations screen says so — a dead endpoint retried forever is a queue of work nobody will receive. Re-enable it (after fixing the receiver) from the settings screen or with PATCH /api/v1/webhook_endpoints/{id} and {"active": true}.

Delivery history is kept for 30 days, attempt by attempt, and any delivery in that window can be replayed from Settings, Integrations. A replay carries the original payload and envelope id under a new webhook-id, so a receiver that deduplicates on the envelope id sees it as the event it already has.

Secret rotation

A planned rotation issues a new secret and keeps the old one signing for 24 hours — deploy the new secret at your leisure inside the window; at least one signature in the header verifies throughout. The new secret is shown once, in the response to the rotation. For a leaked secret, the settings screen offers an immediate regeneration with no overlap.

Test deliveries

The integrations screen's test button sends a synchronous webhook.test event through the real dispatcher — same signing, same headers — and reports whether your endpoint accepted it. It is deliberately not subscribable: it is a delivery-path check, not something that happens in the product. Its envelope id is a freshly generated evt_... id like any other event's, new for every test, so it exercises the same parsing and deduplication path; data carries only a message.

Commencez avec un fournisseur. Gratuit, sans carte.

Cherchez une entreprise, suivez-la et voyez la chaîne qui s'étend en dessous. Passez à l'offre supérieure quand un fournisseur ne suffit plus.