Webhook subscriptions

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_kindWhen it firesRequired fieldsNotes
immediatePer event — fan-out happens inside ReviewHistoryObserver the moment the audit row is writtenevents (optional filter), payload_mode, suppress_native_notifications applyUse this slot for time-critical signals (flag actions, profanity, post failures, removals)
batchEvery batch_interval_hours hours by the scheduler — coalesces all review-impacting activity since last_batch_dispatched_atbatch_interval_hours ∈ 12Event 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

  1. Partner-admin POSTs /partner/webhooks with at minimum {label, webhook_url, delivery_kind} (+ batch_interval_hours if batch).
  2. 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.
  3. For immediate subs: every matching review_history insert triggers, then DispatchPartnerWebhooks, then DeliverWebhook.
  4. For batch subs: the scheduler wakes up every batch_interval_hours, dispatches one job per qualifying review since last_batch_dispatched_at, then advances the cursor.
  5. Each attempt writes a row to partner_webhook_deliveries. On 2xx the subscription's consecutive_failure_count resets 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 number
  • X-SAU-Signaturesha256=<hex> of hash_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) or post (response posting). The two axes are independent: a site can keep pulling reviews while posting access is lost, so you may receive response_post_disconnected without review_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_deliveries row 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, default full) — minimal ships 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. full keeps the original contract with the full review embed.
  • suppress_native_notifications (boolean, default false) — (broadened 2026-06). When true on 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 (requiring homeowner_notified_of_response in events) 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 Authorization header. 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

StatusMeaning
401The bearer token is missing, expired or invalid
403The token is valid but the record sits outside your account
404No record matches the id you sent
422The request failed validation — the response names the fields
500Unexpected server error

Individual endpoints may return more; each page lists its own.

Endpoints

MethodEndpointDescription
GETList my subscriptionsList every active (non-soft-deleted) webhook subscription owned by the caller's partner company, newest first.
POSTCreate subscription (raw secret returned)Create an outbound webhook subscription for the caller's partner.
GETList subscribable eventsGet the full catalog of events the caller's partner can subscribe to, plus the allowed batch_interval_hours choices.
GETShow one subscriptionGet a single webhook subscription scoped to the caller's partner.
POSTUpdate subscription (no secret)Partially updates a subscription owned by the caller's partner — every field is optional; send only what changes.
POSTRotate HMAC secretGenerate a new 256-bit HMAC secret and atomically replaces the stored ciphertext + fingerprint.
POSTReveal current HMAC secretReturns the CURRENT raw HMAC secret (decrypted) so a partner-admin can re-copy it if the value shown at create/rotate time was lost.
POSTFire a test deliveryDispatches 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.
GETRecent delivery attemptsGet recent delivery attempts for the subscription, newest first (orderByDesc('id')).
DELETEDelete 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.