Webhook subscriptions
Partner-admin outbound webhook subscriptions — fan out every review_history audit action to a partner-owned webhook endpoint.
Partner-admin outbound webhook subscriptions.
Every action captured in the review_history audit trail can be fanned out to a partner-owned webhook endpoint. The partner owns the subscription URL, an optional event filter, and the HMAC secret used to verify deliveries.
Two-slot delivery model (current — replaces the legacy 3-tier model)
Each partner can have at most one IMMEDIATE subscription and one BATCH subscription at the same time (independent URLs / secrets / event filters). The slot is chosen at create time via delivery_kind:
delivery_kind | When it fires | Required fields | Notes |
|---|---|---|---|
immediate | Per event — fan-out happens inside ReviewHistoryObserver the moment the audit row is written | events (optional filter), payload_mode, suppress_native_notifications apply | Use this slot for time-critical signals (flag actions, profanity, post failures, removals) |
batch | Every batch_interval_hours hours by the scheduler — coalesces all review-impacting activity since last_batch_dispatched_at | batch_interval_hours ∈ 12 | Event filter / payload mode / suppress flag are ignored — batch always sends full envelopes for every review-impacting event |
Re-creating a slot is blocked while the existing one is non-soft-deleted: delete it first or POST /update the existing row. After a soft-delete the slot frees up immediately.
Lifecycle
- Partner-admin POSTs
/partner/webhookswith at minimum{label, webhook_url, delivery_kind}(+batch_interval_hoursifbatch). - Server generates a 256-bit raw secret, returns it ONCE in the response, and persists only the encrypted ciphertext + an 8-byte display fingerprint.
- For
immediatesubs: every matchingreview_historyinsert triggers, thenDispatchPartnerWebhooks, thenDeliverWebhook. - For
batchsubs: the scheduler wakes up everybatch_interval_hours, dispatches one job per qualifying review sincelast_batch_dispatched_at, then advances the cursor.
Delivery envelope (received by your endpoint)
Method: POST — JSON body, Content-Type: application/json.
Headers:
X-SAU-Event— event value (e.g.profanity_detected,response_post_succeeded,webhook.test)X-SAU-Delivery— ULID unique to this event (idempotency key)X-SAU-Attempt— current attempt numberX-SAU-Signature—sha256=<hex>ofhash_hmac('sha256', RAW_BODY, RAW_SECRET)
Body: Canonical envelope —
{
"event": "profanity_detected",
"event_id": "01HXYZ...",
"occurred_at": "2026-05-28T09:42:01+00:00",
"delivered_at": "2026-05-28T09:42:01+00:00",
"partner": {
"id": 5
},
"actor": {
"type": "system",
"id": null,
"name": "profanity-scan"
},
"review": {
"id": 769,
"external_review_id": "hp-12345",
"store_id": 2,
"review_site_id": 1,
"source_platform": "hipages",
"rate": 1,
"reviewer": "Anon"
},
"metadata": {
"any": "action-specific"
}
}Site-connection event envelope (reviewpull / responsepost)
Connection events use a different body shape (no review / actor embed). The same X-SAU-* headers and HMAC signing apply.
{
"event": "review_pull_disconnected",
"event_id": "01HXYZ...",
"occurred_at": "2026-06-22T09:42:01+00:00",
"delivered_at": "2026-06-22T09:42:01+00:00",
"partner": {"id": 5},
"store": {"id": 812},
"review_site": {
"store_review_site_id": 4410,
"review_site_id": 1,
"name": "Google",
"connection_status": "disconnected",
"disconnect_reason": "Google connection has expired. Please reconnect this location."
},
"axis": "pull",
"error_code": "E-002",
"reason": "Google connection has expired. Please reconnect this location."
}axis-pull(data pulling / scraping) orpost(response posting). The two axes are independent: a site can keep pulling reviews while posting access is lost, so you may receiveresponse_post_disconnectedwithoutreview_pull_disconnected.error_code- stable catalog code, present on the disconnected events (e.g. E-001 no URL configured, E-002 expired credentials, E-007 two-factor required, E-008 data pulling paused). Null on the connected events.reason- layman-friendly explanation safe to surface to an end user. Null on connected events.- An event fires only on a per-axis transition (connected vs disconnected), never on every write.
Verifying the signature (server side)
import hmac, hashlib
expected = 'sha256=' + hmac.new(SECRET.encode(), RAW_BODY_BYTES, hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, request.headers['X-SAU-Signature'])Retry & auto-disable
- One
partner_webhook_deliveriesrow per attempt (full per-attempt audit trail)
Auth
All endpoints require the top-level collection bearer (Authorization: Bearer your bearer token, set automatically by Login using the email / password environment variables). The token user MUST belong to a company with bundle_id=1 (partner-admin). Account-admins and tradies receive 403 with audit-logged AUTHZ_DENIED. Cross-tenant subscription IDs return 404 (not 403) — least-information leak.
Spec v4.26 fields (immediate-slot only)
payload_mode(full|minimal, defaultfull) —minimalships only the cache-invalidation envelope (event, event_id, occurred_at, partner.id, review.id, review_history_id) so the partner uses it as a refetch ping.fullkeeps the original contract with the full review embed.suppress_native_notifications(boolean, defaultfalse) — (broadened 2026-06). Whentrueon an active subscription, SAU skips every native partner-action email for the partner tree: homeowner notify on response posted, flag-status changes to account owner, and future review-action emails as they are added. The original per-event opt-in check (requiringhomeowner_notified_of_responseinevents) is no longer enforced — the webhook fan-out fires reliably for every relevant audit row, so the partner is guaranteed a replacement signal.
The endpoint accepts these two fields on batch subscriptions but pinned to safe defaults server-side (full / false) — only the immediate slot honours them. The legacy delivery_tier_override field has been REMOVED — use delivery_kind + batch_interval_hours instead.
Prerequisites
- A bearer token in the
Authorizationheader. Any endpoint that needs no token says so on its own page. - The id of each record the call targets. Every endpoint page lists the ids it needs.
Errors
| Status | Meaning |
|---|---|
401 | The bearer token is missing, expired or invalid |
403 | The token is valid but the record sits outside your account |
404 | No record matches the id you sent |
422 | The request failed validation — the response names the fields |
500 | Unexpected server error |
Individual endpoints may return more; each page lists its own.
Endpoints
GET — List every active (non-soft-deleted) webhook subscription owned by the caller's partner company, newest first.
POST — Create an outbound webhook subscription for the caller's partner. The raw HMAC secret appears in this response only — re-read it later with reveal-secret, or replace it with rotate-secret.
GET — Get the full catalog of events the caller's partner can subscribe to, plus the allowed batch_interval_hours choices.
POST — Partially updates a subscription owned by the caller's partner — every field is optional; send only what changes.
POST — Generate a new 256-bit HMAC secret and atomically replace the stored ciphertext + fingerprint.
POST — Returns the CURRENT raw HMAC secret (decrypted) so a partner-admin can re-copy it if the value shown at create/rotate time was lost.
POST — Dispatches a synthetic webhook.test delivery to the subscription's URL (via the same DeliverWebhook job + per-attempt retries as production) so partners can verify their endpoint and HMAC verifier.
GET — Get recent delivery attempts for the subscription, newest first (orderByDesc('id')).
DELETE — Soft-delete the subscription (sets deleted_at) and forces is_active=false in one transaction so any already-queued retry attempts exit silently.
Updated 8 days ago
