Webhooks

Live Last updated

Signed events about your deals — the Standard Webhooks signing scheme, the events we send, retries, and the delivery log you can replay.

Live. Endpoint registration, signing, delivery with retries, the per-delivery log, replay and test events all work today. Event types are registered in the catalogue as their lanes land; today deal.created, deal.expired, deal.rebased, deal.escalated and webhook.test fire — the rest fire as their lanes ship. The OpenAPI document is the live registry.

When something happens on a deal, we tell your systems: a signed webhook to an endpoint you register. Verify the signature, react to the events you care about, and ignore the rest.

How a delivery works#

Each delivery is an HTTP POST carrying three headers, per the Standard Webhooks specification:

  • webhook-id — unique to this delivery; your dedupe key
  • webhook-timestamp — Unix seconds
  • webhook-signature — v1,<base64 signature>; several space-separated signatures appear while you rotate keys

The signature is HMAC-SHA256, keyed by your endpoint secret (base64 after its whsec_ prefix), computed over the raw bytes <webhook-id>.<webhook-timestamp>.<request body>. Three rules keep verification sound:

  • verify against the raw request body — parse the JSON only after the signature checks out;
  • reject deliveries whose timestamp is more than five minutes from now, in either direction — the replay window, two-sided for clock skew;
  • fail closed on malformed or missing headers; an attacker controls the headers, so verification is written to return false, never to throw.

Go#

// Verifying a webhook delivery signed per the Standard Webhooks scheme:
// HMAC-SHA256 over "<webhook-id>.<webhook-timestamp>.<raw request body>",
// base64-encoded, compared in constant time, inside a replay tolerance
// window in both directions. Run with: go run webhook_verify.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strconv"
	"strings"
	"time"
)

// verify checks sigHeader (e.g. "v1,BASE64SIG"; several space-separated
// signatures appear during key rotation) against the delivery. In a real
// handler, pass time.Now() and a 5-minute tolerance. Malformed or missing
// headers and timestamps outside the window fail closed.
func verify(key []byte, msgID string, ts int64, payload []byte, sigHeader string, now time.Time, tolerance time.Duration) bool {
	age := now.Sub(time.Unix(ts, 0))
	if age < 0 {
		age = -age
	}
	if age > tolerance {
		return false // outside the replay window, past or future
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(msgID))
	mac.Write([]byte("."))
	mac.Write([]byte(strconv.FormatInt(ts, 10)))
	mac.Write([]byte("."))
	mac.Write(payload)
	want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, part := range strings.Split(sigHeader, " ") {
		scheme, sig, ok := strings.Cut(part, ",")
		if ok && scheme == "v1" && hmac.Equal([]byte(sig), []byte(want)) {
			return true
		}
	}
	return false
}

func main() {
	// Known test vector. Never embed a real secret in code.
	const (
		msgID   = "msg_test_vector_01"
		ts      = int64(1793966400)
		payload = `{"id":"evt_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","type":"deal.created","version":1,"created_at":"2026-11-06T12:00:00Z","data":{"deal":{"id":"deal_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","state":"open","offer_id":"offer_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","configuration_version_id":"cfgver_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","buyer_email":"[email protected]","email_verified_at":"2026-11-06T12:00:00Z","created_at":"2026-11-06T12:00:00Z","updated_at":"2026-11-06T12:00:00Z"},"buyer_user_id":"bu_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N"},"api_url":"https://app.example.com/api/v1/deals/deal_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N"}`
		sig     = "v1,Dif/+cIH2ZW+IetdRTbZPP3f+Wn3YG9G4N9CqN/SOU0="
	)
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix("whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", "whsec_"))
	if err != nil {
		panic(err)
	}
	now := time.Unix(ts, 0)
	fmt.Printf("good: %v\nbad: %v\n",
		verify(key, msgID, ts, []byte(payload), sig, now, 5*time.Minute),
		verify(key, msgID, ts, []byte(`{"id":"tampered"}`), sig, now, 5*time.Minute))
	// stale and future keep the valid signature and move the injected
	// now, so only the replay window can reject them; malformed and
	// missing headers fail closed without panicking.
	fmt.Printf("stale: %v\nfuture: %v\nmalformed: %v\nmissing: %v\n",
		verify(key, msgID, ts, []byte(payload), sig, now.Add(-10*time.Minute), 5*time.Minute),
		verify(key, msgID, ts, []byte(payload), sig, now.Add(10*time.Minute), 5*time.Minute),
		verify(key, msgID, ts, []byte(payload), "v1", now, 5*time.Minute),
		verify(key, msgID, ts, []byte(payload), "", now, 5*time.Minute))
}

JavaScript (Node)#

// Verifying a webhook delivery signed per the Standard Webhooks scheme:
// HMAC-SHA256 over "<webhook-id>.<webhook-timestamp>.<raw request body>",
// base64-encoded, compared in constant time, inside a replay tolerance
// window in both directions. Run with: node webhook_verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

// verify checks sigHeader (e.g. "v1,BASE64SIG"; several space-separated
// signatures appear during key rotation) against the delivery. In a real
// handler, pass Date.now() and a 5-minute tolerance. Malformed or missing
// headers and timestamps outside the window fail closed.
export function verify(key, msgID, ts, payload, sigHeader, nowMs, toleranceMs) {
	if (typeof sigHeader !== 'string') return false; // missing header fails closed
	const age = nowMs - ts * 1000;
	if (Math.abs(age) > toleranceMs) return false; // outside the replay window, past or future
	const want = createHmac('sha256', key)
		.update(`${msgID}.${ts}.${payload}`)
		.digest('base64');
	return sigHeader.split(' ').some((part) => {
		const [scheme, sig] = part.split(',');
		if (scheme !== 'v1' || typeof sig !== 'string' || sig.length === 0) return false;
		const a = Buffer.from(sig);
		const b = Buffer.from(want);
		return a.length === b.length && timingSafeEqual(a, b);
	});
}

// Known test vector. Never embed a real secret in code.
const secretB64 = 'whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='.replace(/^whsec_/, '');
const key = Buffer.from(secretB64, 'base64');
const msgID = 'msg_test_vector_01';
const ts = 1793966400;
const payload = '{"id":"evt_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","type":"deal.created","version":1,"created_at":"2026-11-06T12:00:00Z","data":{"deal":{"id":"deal_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","state":"open","offer_id":"offer_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","configuration_version_id":"cfgver_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N","buyer_email":"[email protected]","email_verified_at":"2026-11-06T12:00:00Z","created_at":"2026-11-06T12:00:00Z","updated_at":"2026-11-06T12:00:00Z"},"buyer_user_id":"bu_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N"},"api_url":"https://app.example.com/api/v1/deals/deal_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N"}';
const sig = 'v1,Dif/+cIH2ZW+IetdRTbZPP3f+Wn3YG9G4N9CqN/SOU0=';
const tolerance = 5 * 60 * 1000;

// good: the vector verifies; bad: a tampered payload does not. stale and
// future keep the valid signature and move the injected now, so only the
// replay window can reject them; malformed and missing headers fail
// closed without throwing.
const now = ts * 1000;
console.log(`good: ${verify(key, msgID, ts, payload, sig, now, tolerance)}`);
console.log(`bad: ${verify(key, msgID, ts, '{"id":"tampered"}', sig, now, tolerance)}`);
console.log(`stale: ${verify(key, msgID, ts, payload, sig, now - 600 * 1000, tolerance)}`);
console.log(`future: ${verify(key, msgID, ts, payload, sig, now + 600 * 1000, tolerance)}`);
console.log(`malformed: ${verify(key, msgID, ts, payload, 'v1', now, tolerance)}`);
console.log(`missing: ${verify(key, msgID, ts, payload, undefined, now, tolerance)}`);

Events#

Every event type is named resource.event, catalogued machine-readably at the webhooks.v1.json URL the deployment's meta.discovery advertises, and subscribable as ["*"] or per type. The catalogue is additive: types are never removed, and a receiver ignores a type it doesn't know rather than rejecting the delivery.

The catalogue's registered types include:

  • deal.created — the buyer verified their email; the deal is live. Fires exactly once per deal.
  • deal.signed — all required signatures collected; the contract exists.
  • deal.paid — payment confirmed through your billing integration.
  • deal.closed — your done-condition is fully satisfied.
  • deal.expired, deal.rebased, deal.escalated, deal.abandoned — the versioning and lifecycle events.
  • contract.signature_added, contract.completed — signing progress.
  • usage-period.reported, usage-period.corrected, usage-reconciliation.drifted — metering and reconciliation.
  • webhook.test — the test event from the endpoint's test operation.

A type fires once the lane that produces it ships; until then it sits in the catalogue as registered-but-not-yet-emitted, so your receiver can subscribe ahead of time.

The envelope#

Every event is the same versioned envelope — six members, always, in this order:

{
  "id": "evt_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N",
  "type": "deal.created",
  "version": 1,
  "created_at": "2026-11-06T12:00:00Z",
  "data": { "deal": { "…": "…" } },
  "api_url": "https://app.example.com/api/v1/deals/deal_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N"
}
  • id — the event's id: your dedupe key, shared with the webhook-id header.
  • type — the resource.event type, from the catalogue.
  • version — the envelope version the catalogue describes.
  • created_at — when the event happened, RFC 3339.
  • data — the event's payload, keyed by resource name (decision 3x0g): the subject — the resource segment of the type, hyphens as underscores (usage-period.reported keys its subject under data.usage_period) — is always there, carrying the API's own member names for that resource (data.deal for deal events, data.contract for contract events, data.webhook for the test event). Further resources involved in the event sit beside it under their own names — deal.escalated carries the deal and its escalation. Facts that belong to the event rather than to any resource — the configuration version a rebase left, the buyer user whose verification created the deal — are plain members at the data root. The machine-readable webhooks.v1.json catalogue is the registry.
  • api_url — the deployment's own API — the hosted platform's, or yours if you self-host — naming the subject resource in the API, which remains the source of truth.

Ordering, retries, replay#

  • No ordering is guaranteed. Deliveries can arrive out of order. Dedupe on webhook-id and treat the snapshot's timestamps as the ordering hint.
  • Retries: a failed delivery backs off exponentially for about 24 hours, after which the endpoint is marked failing. The delivery log and replay are available through the API: every attempt is recorded with its status code, latency and (truncated) response body, and a delivery can be re-sent byte-identically — same webhook-id, so your dedupe holds.
  • Escalations ride the same mechanism: an escalation notification is a webhook event like any other — one receiver, one verification path.

The provisioning callback#

When "provisioned" is part of your done-condition, the loop closes through the API: you confirm provisioning for the deal, with an optional buyer-facing URL and message. Payment before it is event-driven too: the billing integration's invoice.paid delivery lands the order's payment (the deployment's ingest route and hourly sweep cover a missed one), and the same confirmation closes the done-condition — deal.paid, then deal.closed when the last component holds.