React to events with webhooks

Configure your webhooks URL, pass the verification challenge, verify every signature, acknowledge within five seconds, and recover missed events by polling.

Webhooks push an HTTP POST to your server when something changes on your account: a review arrives, a listing goes live, a post is published. There is one URL per account and no per-event subscription, so one receiver gets all 18 documented events and branches on the event field.

1. Set the URL and secret in the dashboard

Build the receiver in steps 2 and 3 first. Generating a secret makes signing mandatory and pauses delivery until your endpoint passes verification, so an endpoint that is not ready blocks every event.

  1. Stand up the HTTPS endpoint. Private addresses are refused, and redirects are not followed, so save the final URL with no www or trailing-slash redirect in front of it.
  2. Save the URL on the dashboard's Webhooks page.
  3. Generate a signing secret. It is shown once. Store it as LISTINGSAPI_WEBHOOK_SECRET on your server.
  4. Click Verify endpoint. The platform sends the challenge from step 2 and waits up to 10 seconds.

Changing the URL or regenerating the secret resets verification, so deploy the new secret first, then verify again. Details are in Webhooks overview.

2. Answer the verification challenge

The challenge is a signed POST with a reserved event name and a nonce. Reply 200 with the HMAC-SHA256 of the nonce alone, as lowercase hex.

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

3. Verify the signature on every delivery

Every signed request carries X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Compute the same digest over the exact bytes you received, compare in constant time, and return 401 on a mismatch. Do not parse and re-serialize the JSON first; that changes the bytes. Both receivers below keep the raw body, verify, answer the challenge, and store the event durably before replying.

Flask receiver
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode()  def signature_is_valid(raw_body: bytes, header: str | None) -> bool:  digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest()  expected = "sha256=" + base64.b64encode(digest).decode()  return hmac.compare_digest(header or "", expected)  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")):      abort(401)   body = request.get_json(silent=True) or {}   # One-time handshake: hex digest of the nonce, not the body.  if body.get("event") == "endpoint.verification":      return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200   # Real event: durable write first, then acknowledge. Swap for your queue.  inbox.put(raw)  # must be committed before the 200 goes out  return {"received": True}, 200
Express receiver
import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {  return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') {  const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex');  return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});

If an account has never generated a secret, deliveries arrive unsigned. Do not build on that mode; generate the secret once the receiver above is live.

4. Acknowledge fast and process async

A delivery is one POST with a 5-second connect and 5-second read timeout, attempted exactly once. There is no retry, no backoff and no replay. If your handler takes six seconds, or your server is down for two minutes, those events are gone. Only a 2xx counts as delivered; 3xx, 4xx and 5xx are failures.

So the request path does three things only: verify, write the raw body to a queue or table and wait for the commit, return 200. Enrichment, calls back into the REST API and anything touching a third party run from the queue. Every event except listing.submission shares one envelope:

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" }}

location_id is a numeric string, except on interaction.* events where it is passed through unnormalized and may be null. listing.submission has no timestamp or account_id and puts status and live_link at the top level. Optional fields in data are omitted rather than sent as null. Return 200 for any event you do not recognize; undocumented families share the same URL.

5. Dedupe and tolerate out-of-order delivery

You can receive the same event twice, and you will receive the same resource id many times: interaction.review re-fires with the same interaction id each time the review changes, and no payload carries a version. So never suppress on id plus event name, or you will drop real edits. Detect exact duplicates by hashing the raw body and ignoring a repeat within a short window, then make your write idempotent on the resource id.

Exact-duplicate check
import hashlibimport time SEEN: dict[str, float] = {}  # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool:  key = hashlib.sha256(raw_body).hexdigest()  now = time.time()  for k, seen_at in list(SEEN.items()):      if now - seen_at > WINDOW_SECONDS:          del SEEN[k]  if key in SEEN:      return True  SEEN[key] = now  return False

Ordering is not guaranteed either. Events come from independent rails, so local_post.published can land before local_post.created, and timestamp is stamped when the delivery is built, not when the change happened. When order matters, read the resource back from the REST API and trust that read. profile.updated is also debounced to one event per location per 60 seconds, so a read-back on arrival sees only the first edit; read again after the window closes.

Branch on event and hand each one to the code you wrote for the matching guide. The webhook says when; the endpoint in that guide says what.

EventWhat happenedGuide to read
profile.createdA location was created. data.source is user, api or bulk_edit.Add a location
profile.updatedA location was edited. data.changed_fields is a hint, not a full diff.Update a location
profile.deletedA location was archived.Find and archive locations
listing.submissionA publisher submission resolved: status is success, incomplete or canceled.Check listing status and live links
connection.location_connected, connection.location_disconnectedA Google or Facebook listing was linked or unlinked.Connect Google and import locations, Connect Facebook Pages
connection.reauth_required, connection.listing_inaccessibleCredentials expired, or access to a listing was lost.Connect Google and import locations
connection.google_verification_verified, connection.google_verification_failedA Google verification finished.Create a Google Business Profile
interaction.reviewA review arrived or changed. Fields sit under data.interaction.Fetch reviews
interaction.responseAn owner reply was posted, edited or archived. parent_id is the review.Respond to reviews
local_post.created, local_post.published, local_post.rejected, local_post.deletedA post item moved state. Key on data.post_item_id, one per location.Publish posts
review_analytics.daily_snapshot, review_analytics.weekly_snapshotThe review roll-up ran for one location and period.Review analytics

Field tables and a sample payload for every event are in the event reference.

7. Recover from a missed event by polling

Because there is no retry, every family needs an authoritative read you can fall back on. Reconcile on a schedule, not only when something looks wrong, and never write a zero or "no change" because an event did not arrive.

FamilyRead back withInterval
listing.submissionGET /locations/{locationId}/listings/premium for syncStatus and listingUrl.Daily per location.
profile.*GET /locations or GET /locations/search; updatedAt shows what moved.Hourly, plus 60 seconds after each profile.updated.
connection.*GET /connected-accounts and googleVerificationStatus on any location read.Hourly; every few minutes during a verification.
interaction.*GET /rollup_interactions across the account, GET /reviewDetails by id.Every 15 minutes for new reviews, daily for a sweep.
local_post.*GET /posts/{postId} or GET /locations/{locationId}/posts.Hourly while posts are in flight.
review_analytics.*GET /locations/{locationId}/review-analytics-overview with the period's dates.Daily after the roll-up.

The dashboard records every attempt with a status. success is delivered; an HTTP code, timeout, network_error or error means your endpoint was reached and failed; blocked_url, endpoint_unverified and rate_limited mean no request was made at all, so the fix is configuration or plan, not code. The full list is in Delivery behavior.

Next steps