Partner-admin outbound webhook subscriptions.** Every action captured in the review_history audit trail can be fanned out 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 and returns it in the create response. The secret is persisted as encrypted ciphertext plus an 8-byte display fingerprint — it is never stored in plaintext, but a partner-admin can retrieve the current value later via Reveal current HMAC secret.
- 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. - Each attempt writes a row to
partner_webhook_deliveries. On 2xx the subscription'sconsecutive_failure_countresets to 0; on permanent failure it increments. After 20 consecutive permanent failures the subscription auto-disables.
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 (review_pull_ / response_post_)
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
- 4 attempts per event: immediate, +1m, +5m, +30m
- One
partner_webhook_deliveriesrow per attempt (full per-attempt audit trail) - 20 consecutive permanent failures → subscription auto-disabled, re-enable manually
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
| Method | Endpoint | Description |
|---|---|---|
GET | List my subscriptions | List every active (non-soft-deleted) webhook subscription owned by the caller's partner company, newest first. |
POST | Create subscription (raw secret returned) | Create an outbound webhook subscription for the caller's partner. |
GET | List subscribable events | Get the full catalog of events the caller's partner can subscribe to, plus the allowed batch_interval_hours choices. |
GET | Show one subscription | Get a single webhook subscription scoped to the caller's partner. |
POST | Update subscription (no secret) | Partially updates a subscription owned by the caller's partner — every field is optional; send only what changes. |
POST | Rotate HMAC secret | Generate a new 256-bit HMAC secret and atomically replaces the stored ciphertext + fingerprint. |
POST | Reveal current HMAC secret | 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 | Fire a test delivery | 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 | Recent delivery attempts | Get recent delivery attempts for the subscription, newest first (orderByDesc('id')). |
DELETE | Delete subscription (soft + auto-disable) | Soft-delete the subscription (sets deleted_at) and forces is_active=false in one transaction so any already-queued retry attempts exit silently. |
