Webhook event catalogue
Every webhook event: when it's sent, how it's delivered, and what the payload looks like.
This page lists every event a webhook subscription can receive, when each one is sent, and a sample of what arrives at your endpoint. To get the same list from the API, call List the events you can subscribe to.
Two ways to receive events
A partner can have one immediate subscription and one batch subscription at the same time, each with its own URL and secret. You choose with delivery_kind when you create a webhook.
| Immediate | Batch | |
|---|---|---|
| What you receive | Each event as it happens, under its own name | One cache_invalidation per location that had review activity in the window |
| Events covered | Every event below except the batch events | The batch events |
Event filter (events) | Optional: pick the immediate events you want (default: all) | Not used |
| Payload mode | full (default) or minimal | Always the cache_invalidation shape |
| Use it to | React to things: a removal, a reply, a failed post, a disconnected site | Know which locations to refresh: new and changed reviews |
Batch events are never sent under their own name. If you subscribe to batch, you'll see cache_invalidation, never review_pulled_from_platform.
Replies posted directly on a review site
response_receivedis sent when we find a reply to a review that we already had without one: for example, the business replied on Google, Facebook or another review site instead of through us. The reply comes in with the next review pull for that location. If that reply is later edited on the site, you getresponse_updated.If a review and its reply are both new to us, they arrive together: you get the review through
cache_invalidation(batch) and the reply is already on it, soresponse_receivedisn't sent.
Events
Immediate events arrive under their own name: the event field and the X-SAU-Event header carry the names below. Batch events never arrive under their own name; a batch subscription receives cache_invalidation instead.
Batch events
These events are collected into one cache_invalidation per location per window, sent to your batch subscription. They can't be picked on an immediate subscription.
| Event | When it's sent |
|---|---|
review_pulled_from_platform | A new review is collected from a connected review site. |
review_campaign_collected | A review is submitted through one of your review-request campaigns. |
review_updated | An existing review's rating, text, reviewer name or date changes. |
review_ingested | Not sent to your subscriptions today. It's reserved for reviews submitted to us directly. |
Reviews
| Event | When it's sent | Extra fields (metadata) |
|---|---|---|
review_deleted | A review is removed: by a partner user, or because a Google location was reconnected to a different listing. The review is kept in its history. | reason, and when a user removed it, related_soft_deleted (counts of responses, ai_responses, response_revisions, flags removed with it) |
platform_removal_detected | Our weekly check finds that a review is no longer on the review site. | detected_via, removal_status, flag_id (if the review was flagged) |
profanity_detected | Not sent to your subscriptions today. It's reserved for reviews submitted to us directly. | — |
Removal requests (flags)
| Event | When it's sent | Extra fields (metadata) |
|---|---|---|
flag_submitted | Someone submits or edits a removal request on a review. | flag_id, reason, reason_identifier, flag_reason_detail, review_site |
flag_submitted_to_platform | The removal request has been submitted to the review site. | flag_id, previous_flag_status |
flag_denied | The review site declined the removal request and kept the review. | failure_reason, previous_flag_status |
flag_approved_removed | A flagged review is no longer on the review site: the removal worked. | flag_id, detected_via, previous_flag_status |
Responses
| Event | When it's sent | Extra fields (metadata) |
|---|---|---|
response_received | A reply posted directly on the review site is found on a review that had none. See above. | response_id, platform, source (platform), response_date |
response_drafted | A response is saved on a review for the first time. Its status says whether it's waiting to post (pending) or already posted (complete). | response_id, status, review_site_id, submitted_by; when an AI draft was used, ai_review_response_id, ai_edited, ai_edit_ratio, ai_generation_count |
response_updated | An existing response is changed, either through us or directly on the review site. | Through us: as response_drafted. On the site: response_id, platform, source (platform), response_date, previous_response_date |
response_deleted | A response is deleted through us. The review goes back to having no response. | response_id, prior_status, new_status, review_site_id, deleted_response_id |
ai_response_generated | An AI response is drafted for a review. | ai_review_response_id, tone, response_chars, source, overage |
response_submitted_to_platform | A response is queued to post on the review site. For some review sites it's sent again when the site accepts the post. | response_id, platform |
response_post_succeeded | The review site accepted the response. | response_id, platform, new_status |
response_post_failed | Posting the response failed, for example because the review no longer exists or the business already replied. | response_id, platform, new_status, reason |
response_status_changed | The review site reported a response status other than posted or failed, for example still pending. | response_id, platform, new_status |
response_resubmitted | A response is posted again: because the reviewer edited the review after the reply, or because a pending response is retried. | response_id, platform |
Review site connections
These events use the connection payload. They're sent when a location's review site changes between connected and disconnected, separately for collecting reviews (axis: pull) and posting responses (axis: post).
| Event | When it's sent |
|---|---|
review_pull_connected | We can collect reviews from the site again. |
review_pull_disconnected | We can no longer collect reviews from the site. error_code and reason say why. |
response_post_connected | We can post responses on the site again. |
response_post_disconnected | We can no longer post responses on the site. error_code and reason say why. |
Plan usage
These events use the usage payload. Each is sent at most once per location, feature and threshold in a calendar month.
| Event | When it's sent |
|---|---|
usage.threshold_warning | A location has used 80% of a feature's monthly allowance (75% for allowances of 6 to 20). Not sent for allowances of 5 or fewer. |
usage.limit_reached | A location has used its full monthly allowance for a feature. |
Plans and billing
These events use the billing payload.
| Event | When it's sent |
|---|---|
plan.cancelled | A location's plan is cancelled. Sent once per location. |
brand_plan.cancelled | An account's subscription is cancelled. |
plan.changed | An account's plan is changed, or locations are added to a plan mid-cycle. Sent once per account. |
Payloads
Every payload has these fields:
| Field | Type | Description |
|---|---|---|
event | string | The event name, the same as the X-SAU-Event header. |
event_id | string | Unique id for this event, the same as X-SAU-Delivery. Retries keep the same id, so use it to ignore duplicates. |
occurred_at | string | When it happened (ISO 8601). For cache_invalidation, the end of the window. |
delivered_at | string | When this delivery was sent (ISO 8601). |
partner.id | integer | Your partner id. |
Payloads may carry fields not listed here. Ignore fields you don't recognise.
Review events (full)
The default for an immediate subscription. Used by the review, removal request and response events.
| Field | Type | Description |
|---|---|---|
payload_mode | string | full. |
actor.type | string | Who did it: system, partner_admin, brand_admin, or tradie for any other user (for example a location user). |
actor.id | integer or null | The user's id; null for system. |
actor.name | string | The user's name, or system. |
review.id | integer | The review's id. |
review.external_review_id | string | The review's id on the review site. |
review.store_id | integer | The location. |
review.review_site_id | integer | The review site. |
review.source_platform | string | The review site's name, e.g. Google. |
review.rate | integer | Star rating. |
review.reviewer | string | The reviewer's name. |
metadata | object | Extra fields for the event, listed in the tables above. |
{
"event": "response_received",
"event_id": "01K6HG7B2C9D4E6F8G0H1J2K3M",
"occurred_at": "2026-10-02T09:20:00+00:00",
"delivered_at": "2026-10-02T09:20:02+00:00",
"partner": {
"id": 4521
},
"payload_mode": "full",
"actor": {
"type": "system",
"id": null,
"name": "system"
},
"review": {
"id": 279309,
"external_review_id": "AbFvOqm1x7Kc",
"store_id": 3007,
"review_site_id": 44,
"source_platform": "Google",
"rate": 4,
"reviewer": "Jordan P."
},
"metadata": {
"response_id": 88412,
"platform": "google",
"source": "platform",
"response_date": "2026-10-02 08:55:13"
}
}Review events (minimal)
Sent instead when the subscription's payload_mode is minimal: just enough to know what to refetch.
{
"event": "response_received",
"event_id": "01K6HG7B2C9D4E6F8G0H1J2K3M",
"occurred_at": "2026-10-02T09:20:00+00:00",
"delivered_at": "2026-10-02T09:20:02+00:00",
"partner": {
"id": 4521
},
"payload_mode": "minimal",
"review_history_id": 1184522,
"review": {
"id": 279309
}
}cache_invalidation
Sent to a batch subscription: one per location that had new or changed reviews in the window. Refetch that location's reviews with Get reviews.
| Field | Type | Description |
|---|---|---|
store.id | integer | The location to refresh. |
window.start | string | Start of the window (ISO 8601). |
window.end | string | End of the window (ISO 8601). |
window.interval_hours | integer | The subscription's batch_interval_hours. |
Windows line up with your interval from midnight UTC. A 6-hour subscription, for example, covers 00:00–06:00, 06:00–12:00 and so on. Locations with no review activity in a window get nothing.
{
"event": "cache_invalidation",
"event_id": "3F9A1C0B7E2D4A6F8B1C3D5E7F",
"occurred_at": "2026-10-02T12:00:00+00:00",
"delivered_at": "2026-10-02T12:00:41+00:00",
"partner": {
"id": 4521
},
"store": {
"id": 3007
},
"window": {
"start": "2026-10-02T06:00:00+00:00",
"end": "2026-10-02T12:00:00+00:00",
"interval_hours": 6
}
}Connection events
Always sent in full, whatever the payload mode.
| Field | Type | Description |
|---|---|---|
store.id | integer | The location. |
review_site.store_review_site_id | integer | The location's connection to the site. |
review_site.review_site_id | integer | The review site. |
review_site.name | string | The review site's name. |
review_site.connection_status | string | The connection's status when this was sent. |
review_site.disconnect_reason | string or null | Why it's disconnected, in plain words. |
axis | string | pull (collecting reviews) or post (posting responses). |
error_code | string or null | A stable code on the disconnected events, e.g. E-001 (no URL set up), E-002 (sign-in expired). |
reason | string or null | Why it's disconnected: a short code such as no_url, or a sentence you can show to the business. null on the connected events. |
{
"event": "review_pull_disconnected",
"event_id": "01K6HH0A1B2C3D4E5F6G7H8J9K",
"occurred_at": "2026-10-02T10:02:11+00:00",
"delivered_at": "2026-10-02T10:02:13+00:00",
"partner": {
"id": 4521
},
"store": {
"id": 3007
},
"review_site": {
"store_review_site_id": 55102,
"review_site_id": 44,
"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."
}Usage events
| Field | Type | Description |
|---|---|---|
data.company_id | integer | Your partner id. |
data.store_id | integer | The location. |
data.feature | string | The plan feature, e.g. response_posting, ai_response. |
data.threshold_pct | integer | 80 or 75 for a warning, 100 when the limit is reached. |
data.period.year, data.period.month | integer | The month the allowance belongs to. |
data.units_consumed | integer | Warnings at 80%: units used so far. |
data.units_remaining | integer | Warnings: units left this month. |
data.projected_days_remaining | integer or null | Warnings at 80%: estimated days until the allowance runs out. |
data.message | string | Limit reached: a short message. |
In minimal mode, only the common fields and payload_mode are sent.
{
"event": "usage.threshold_warning",
"event_id": "01K6HH4Q8R2S6T0V4W8X2Y6Z0A",
"occurred_at": "2026-10-02T11:30:00+00:00",
"delivered_at": "2026-10-02T11:30:02+00:00",
"partner": {
"id": 4521
},
"payload_mode": "full",
"data": {
"company_id": 4521,
"store_id": 3007,
"feature": "response_posting",
"threshold_pct": 80,
"period": {
"year": 2026,
"month": 10
},
"units_consumed": 40,
"units_remaining": 10,
"projected_days_remaining": 7
}
}Billing events
data carries names and dates, not ids.
| Event | data fields |
|---|---|
plan.cancelled | store_name, brand_name, partner_name, plan_name, plan_start_date, cancelled_date, cancelling_reason, cancelling_reason_other |
brand_plan.cancelled | company_type (Brand or Partner), company_name, partner_name, total_locations, cancelled_date, cancelling_reason, cancelling_reason_other |
plan.changed | brand_name, partner_name, total_locations, plan_name, plan_start_date, plan_end_date |
In minimal mode, data is left out.
{
"event": "plan.cancelled",
"event_id": "01K6HJ1M3N5P7Q9R1S3T5V7W9X",
"occurred_at": "2026-10-02T13:05:44+00:00",
"delivered_at": "2026-10-02T13:05:46+00:00",
"partner": {
"id": 4521
},
"payload_mode": "full",
"data": {
"store_name": "Acme Coffee - Circular Quay",
"brand_name": "Acme Coffee",
"partner_name": "Acme Partners",
"plan_name": "Growth",
"plan_start_date": "2026-01-15",
"cancelled_date": "2026-10-02",
"cancelling_reason": "too_expensive",
"cancelling_reason_other": null
}
}Test event
Send a test event delivers webhook.test with no review attached, so you can check your endpoint and signature.
{
"event": "webhook.test",
"event_id": "01K6HJ9Z7Y5X3W1V9T7S5R3Q1P",
"occurred_at": "2026-10-02T14:00:00+00:00",
"delivered_at": "2026-10-02T14:00:00+00:00",
"partner": {
"id": 4521
},
"payload_mode": "full",
"actor": null,
"review": null,
"metadata": []
}Receiving a delivery
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-SAU-Event | The event name. |
X-SAU-Delivery | The event_id. The same on every retry. |
X-SAU-Signature | sha256= followed by the HMAC-SHA256 of the raw request body, in lowercase hex, using your subscription's secret. |
X-SAU-Attempt | 1, 2 or 3. |
Verify the signature against the raw body, before parsing it:
import hmac, hashlib
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-SAU-Signature"]):
reject()Reply with any 2xx status within 10 seconds. Anything else, or no answer, counts as a failure.
Retries
- A failed delivery is retried twice: 5 minutes after the first attempt, then 30 minutes after the second.
- Retries carry the same
event_id. Use it to process each event only once. - After 20 failed deliveries in a row, the subscription is switched off. Fix your endpoint, then turn it back on with Update a webhook (
is_active: true). - Events aren't guaranteed to arrive in order. Use
occurred_atto order them. - See recent deliveries shows each attempt and the response your endpoint gave.
Updated about 2 hours ago
