Delivery behavior

The payload envelope, the 5-second budget, why there are no retries, and what every delivery status means.

Webhook delivery is deliberately simple, and simple in a way that has consequences for your receiver: one HTTP attempt, five seconds, no retry. Read this page before you decide how much work your handler does inline.

The envelope

Every event except listing.submission arrives in the same top-level shape, with the event-specific fields nested under data.

JSON
{  "event": "connection.location_connected",  "timestamp": "2026-08-25T14:32:10Z",  "account_id": 11073,  "location_id": "279381",  "data": {    "platform": "google",    "connected_account_id": "ba03bc4b-9f8c-4d3b-8e93-9628034c63cc"  }}
FieldTypeNotes
eventstringThe dotted event name. Branch on this.
timestampstringISO-8601 UTC, stamped when the delivery was built, not when the underlying change happened.
account_idintegerThe account the event belongs to.
location_idstringA numeric string: "279381", not 279381. Not coerced on interaction.*; see the exception below.
dataobjectEvent-specific fields. Documented per event in the event reference.
agency_account_idintegerOnly present when the account is managed by a parent agency account.

Optional fields inside data are omitted when empty rather than sent as null. Code defensively: treat a missing key and an explicit null the same way, and never assume a key that is documented as optional will be present.

Two exceptions

The 5-second budget

Each delivery is a single POST with a 5-second connect timeout and a 5-second read timeout. If your endpoint has not responded in time, the attempt is abandoned and recorded as timeout.

That budget covers your entire handler. The only safe design is:

  1. Verify the signature.
  2. Write the payload somewhere durable: a queue, a table, a log.
  3. Return 200.

Everything else (enrichment, fan-out, calling back into the REST API, anything that touches a third party) belongs on the other side of that queue.

There are no retries

A delivery is attempted exactly once. There is no backoff, no retry queue, and no dead-letter queue. If your endpoint is down for two minutes, the events that fired in those two minutes are gone. They will not arrive later.

Two consequences worth being explicit about:

  • Redirects are not followed. Redirects are disabled outright, so a 301 or 302 from your endpoint is recorded as a failed delivery, not followed to a second URL. Configure your webhooks URL as the final URL. Watch for the bare-domain-to-www and the trailing-slash redirects that many web servers add by default.
  • Only a 2xx counts as delivered. 3xx, 4xx and 5xx are all recorded as failures.

Duplicates and ordering

  • You may receive the same event twice, and the same id for two different changes. The identifiers inside data (an interaction id, a post item id, a connected-account id) name the resource, not the change. interaction.review re-fires with the same interaction id every time the review changes, and no payload carries a version or an updated-at field, so a handler that permanently suppresses on id + event silently throws real edits away. Detect exact duplicates instead: hash the raw body bytes and ignore a repeat of the same hash inside a short bounded window (minutes, not forever). A different body for the same id is a new change, not a duplicate. Then read the resource back from the REST API and treat that read as authoritative; make the write idempotent on the resource id rather than skipping it.
  • Ordering is not guaranteed, and timestamp will not fix it. Events come from several independent rails and are delivered as they arrive, so a local_post.published can land before the local_post.created you expected first. Do not order two changes to the same resource by timestamp: it is stamped when the delivery is built, not when the change happened, and there is no version or sequence number anywhere in the payload. When order matters, read the resource back and use what the API returns.
  • profile.updated is debounced to one delivery per location per 60 seconds, on the leading edge. This is a real collapse, not a delay: the first edit opens the window and is delivered, every edit inside the window is dropped and never arrives, and changed_fields describes only the edit that opened the window. The event therefore reaches you at the start of the window, so a read-back triggered by its arrival can only ever see that first edit. Schedule a second read-back after the window closes — at least 60 seconds after the event — and reconcile locations on a timer as well, because the notification itself can be lost: one attempt, no retry. Nothing else is debounced.

Recovering from a missed event

There is no retry and no replay, so every family needs an authoritative read you can fall back on. Reconcile on a schedule, not only when something looks wrong.

FamilyRead back withSuggested intervalWhat a read cannot recover
listing.submissionGET /locations/{locationId}/listings/premium, once with listingType=premium and once with additional: the per-site syncStatus and listingUrl the event announces.Daily per location, and after a bulk location edit.The legacy payload has no timestamp and no account_id, so a missed one cannot be placed in time or attributed to an account. error_message on an incomplete submission is not exposed by any read.
profile.*GET /locations, GET /locations/search, or GET /locations-by-ids. The updatedAt on each location tells you which ones moved.Hourly, plus the post-window read-back described above.source (user / api / bulk_edit) and changed_fields: a read shows current state, never who changed what. Edits collapsed by the 60-second debounce leave no trace at all, and a deleted location's pre-delete state is gone.
connection.*GET /connected-accounts and GET /connected-accounts/{connectedAccountId}/details for connect, disconnect and auth health. For the Google verification pair, read googleVerificationStatus { status, message } off any location read.Hourly; every few minutes while a verification is in flight.failure_type (rejected vs transient) and the reason string. Flapping is invisible: a connection that dropped and came back reads as connected.
interaction.*GET /locations/{locationId}/reviews per location, GET /rollup_interactions across locations, GET /reviewDetails by id.Every 15 minutes for new reviews, daily for a wider sweep.That something changed. The documented interaction fields carry no updated-at, so an edited review looks exactly like one you already stored: compare the fields you keep.
local_post.*GET /locations/{locationId}/posts, GET /locations/{locationId}/bulk-posts, GET /posts/{postId} for the current per-site publish status.Hourly while posts are in flight.rejection_reason as delivered, and the sequence a post item moved through. Reads give you the current status, not the transitions.
review_analytics.*GET /locations/{locationId}/review-analytics-overview with the period's startDate and endDate.Daily after the roll-up window, weekly for the weekly family.The as-of numbers. The overview is recomputed at read time, so a late-arriving or deleted review moves it away from what the snapshot carried.

Delivery outcomes

Every attempt, including one that never leaves the building, is recorded with a status. These are the exact strings.

The four outcomes and the status strings that record them. 'Blocked' and 'rate limited' mean no HTTP request was made at all, which is why they are worth keeping separate from a genuine failure.
StatusOutcomeWhat it meansWhat to do
successDeliveredYour endpoint returned a 2xx.Nothing.
an HTTP code as a string, e.g. "404", "500"FailedYour endpoint answered with that non-2xx code. 3xx lands here too, because redirects are not followed.Check the path, the redirect chain, and your handler's error rate.
999FailedA response came back with no usable status code.Usually a proxy or load balancer in front of your endpoint.
timeoutFailedNo response within the 5-second budget.Move work off the request path and acknowledge sooner.
network_errorFailedThe host could not be resolved or reached.Check DNS and that the host is publicly resolvable.
errorFailedThe attempt raised something that isn't one of the above, such as a TLS handshake failure.Check your certificate chain and server logs.
blocked_urlBlockedThe URL failed its safety check at dial time: not HTTPS, no host, or it resolved to a private, loopback, link-local or metadata address.Point the URL at a public HTTPS host. Note this is re-checked on every delivery, so a DNS change can start blocking a URL that used to work.
endpoint_unverifiedBlockedThe account has a signing secret, but the endpoint has not passed verification, or verification was reset.Re-run Verify endpoint from the dashboard.
rate_limitedRate limitedThe per-minute or per-day cap was exceeded. The event was dropped.Reduce volume or move to a plan with a higher cap. See plans and limits.

A receiver checklist

  • Verify the signature on every request; return 401 and process nothing on a mismatch.
  • Hash the raw body bytes, never a re-serialized object.
  • Return 2xx in well under 5 seconds; queue the work.
  • Make handlers idempotent: key the write on the IDs inside data, but never treat a repeated id as nothing to do.
  • Don't infer ordering from arrival order, and don't order by timestamp.
  • Normalize location_id to a string on the way in.
  • Ignore event values you don't recognize, and still return 200.
  • Serve the webhooks URL as a final URL, with no redirects.
  • Reconcile periodically against the REST API; there is no retry to save you.