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.
- Stand up the HTTPS endpoint. Private addresses are refused, and redirects
are not followed, so save the final URL with no
wwwor trailing-slash redirect in front of it. - Save the URL on the dashboard's Webhooks page.
- Generate a signing secret. It is shown once. Store it as
LISTINGSAPI_WEBHOOK_SECRETon your server. - 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.
{ "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.
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}, 200import 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:
{ "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.
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 FalseOrdering 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.
6. Map events to the other guides
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.
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.
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
- Fetch reviews: the first family most integrations wire up, with the rollup sweep as its backstop.
- Check listing status and live links:
what
listing.submissionannounces. - Reference: Webhooks overview, Signatures and verification, Delivery behavior, Event reference.