Webhook subscriptions — get notified when X happens
Subscribe a URL to platform events, verify the signature, and respond correctly.
Webhooks push events to your endpoint the moment they happen, so you don't have to poll. Pact signs every delivery, retries failures on a schedule, and dead-letters anything that never succeeds so you can replay it.
Step 1 — Create a subscription
Create one in the app under Admin → Webhooks (/admin/webhooks) or Settings → Webhooks, or via the API:
curl -X POST https://app.pact.place/v1/webhooks/subscriptions \
-H "Authorization: Bearer $PACT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "CRM sync",
"url": "https://api.example.com/pact-webhook",
"event_types": ["contact.created", "contact.updated", "opportunity.*"]
}'
The response includes a signing secret, shown once. Save it — you need it to verify deliveries.
Pick exact event names (contact.created), a domain wildcard (contact.*), or everything (*). Browse the full catalog at GET /v1/webhooks/event-types.
Step 2 — Receive and verify the delivery
Pact POSTs JSON to your URL with these headers:
| Header | Purpose |
|---|---|
X-Pact-Signature | t=<unix_ts>,v1=<hmac_sha256> — verify this |
X-Pact-Event-Id | The event's id — the same on every retry |
X-Pact-Event-Type | e.g. contact.created |
X-Pact-Delivery-Id | Unique per delivery attempt |
Verify the signature before trusting the body. The signed value is "{timestamp}.{raw_body}", HMAC-SHA256 with your secret. Reject anything older than 5 minutes.
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts.get("t", ""), parts.get("v1", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
signed = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
import crypto from "node:crypto";
function verify(secret: string, rawBody: string, header: string, tolerance = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const ts = Number(parts.t);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify against the raw body
Compute the HMAC over the exact bytes you received, before any JSON parsing or re-serialization. Re-encoding the body changes whitespace and breaks the signature.
Deduplicate on the event id
Delivery is at least once: if your 2xx is lost on the way back, Pact retries an event you already
processed. Every retry carries the same X-Pact-Event-Id (also id in the body), and each event is
queued once per subscription. Store the ids you have processed for a few days and answer 2xx to any
you have seen. Replays you trigger yourself get a new id ending in __replay_…, so they are not
swallowed. Order is not guaranteed either: a retry of an older event can arrive after a newer one.
Step 3 — Respond fast
Return a 2xx within 5 seconds. Do slow work asynchronously — acknowledge first, process after.
- Timeout or
5xx→ Pact retries: ~10s, 1m, 10m, 1h, 6h, 24h (7 attempts over ~31 hours), then dead-letters. - Any
4xx(including429) → Pact stops immediately and dead-letters, assuming the request is malformed. There's no retry-then-dead-letter behavior for429specifically — return2xx(or don't rate-limit Pact) to avoid losing the delivery.
Step 4 — Test and replay
Use Send test event in the dashboard (or POST /v1/webhooks/subscriptions/{id}/test) to fire a synthetic webhook.test_fire and confirm connectivity. Inspect every attempt in the delivery log and replay failed or dead-lettered deliveries from Admin → Webhooks.