Signatures and verification
Verify the X-ListingsAPI-Signature header, answer the endpoint verification challenge, and avoid the base64-vs-hex trap.
Your webhooks URL is a public HTTPS endpoint, so anyone on the internet can
POST to it. The signing secret is what lets you tell a real delivery from a
forgery: every signed request carries an HMAC computed with a secret only you
and the platform hold.
Two separate mechanisms use that secret, and they are easy to confuse:
The signature header
Every signed delivery carries:
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )The sha256= prefix is part of the header value. Include it when you build
the expected string, or compare only the part after the =. Nothing is
prepended to the body before hashing: no timestamp, no version, no
concatenated signing string. The HMAC covers the raw body and nothing else.
Verifying, step by step
- Read the raw request body bytes, exactly as received. Do not parse the JSON and re-serialize it. Key order, whitespace and unicode escaping all change the bytes, and any change breaks the HMAC. Most frameworks need to be told to keep the raw body around. See the samples below.
- Compute
HMAC-SHA256(your_secret, raw_body), base64-encode it, and prefixsha256=. - Compare with the header using a constant-time comparison
(
crypto.timingSafeEqual,hmac.compare_digest). A plain==leaks timing information about how many leading bytes matched. - On mismatch, return
401and process nothing.
Endpoint verification
Before real events flow, your endpoint has to prove, once, that it actually holds the secret. This is what stops a stream of signed payloads being sent to a URL that was mistyped, or that belongs to someone who cannot validate it.
You trigger it from the dashboard's Webhooks page. The platform sends a signed challenge and waits up to 10 seconds for the answer. (That 10 seconds applies to the handshake only; real deliveries time out at 5.)
The challenge contract
The challenge is an ordinary signed request, with a reserved event name:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Your endpoint must:
- Verify
X-ListingsAPI-Signatureas it would for any delivery. - Recognize
event == "endpoint.verification"and branch before your normal event handling. - Compute
HMAC-SHA256(secret, nonce)as a lowercase hex string. The HMAC is over the nonce alone, not over the request body. - Reply
200with that hex string as the response body. A bare hex string is expected; a quoted or JSON-string-wrapped hex value is also accepted.
Anything else (a non-2xx, a wrong digest, a timeout, an unreachable host) fails verification.
What resets verification
Verification is not permanent. The account drops back to unverified, and deliveries pause, whenever:
- You change the webhooks URL, or regenerate the secret. The old proof no longer applies to the new configuration.
- A verification attempt fails: including for an endpoint that used to work and has since stopped resolving or responding. Re-running a verification against a broken endpoint will therefore turn deliveries off; that is intentional.
Re-verify once the endpoint is healthy to resume.
Working receivers
Both samples do all three jobs: keep the raw body, verify the signature in constant time, and answer the verification challenge. Both read one secret from the environment: there is exactly one valid secret per account and no overlap window, so do not build a loop that accepts either of two secrets.
const crypto = require('crypto');const express = require('express'); const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET; // Keep the raw bytes. express.json() would otherwise leave you// with only the parsed object, and re-serializing it changes the// bytes the HMAC covers.app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),); function signatureIsValid(rawBody, header) {const digest = crypto .createHmac('sha256', SECRET) .update(rawBody) .digest('base64');const expected = 'sha256=' + digest; const a = Buffer.from(header || '', 'utf8');const b = Buffer.from(expected, 'utf8');// timingSafeEqual throws on a length mismatch, so check first.return a.length === b.length && crypto.timingSafeEqual(a, b);} // The only work allowed before the 200: put the raw bytes somewhere durable// and wait for the write to be acknowledged. Swap the INSERT for your real// queue or table write; it must be committed before you respond, because// nothing is retried if your process dies after the 200 goes out.async function storeForProcessing(rawBody, signature) {await db.query( 'INSERT INTO webhook_inbox (signature, body, received_at) VALUES ($1, $2, now())', [signature, rawBody.toString('utf8')],);} app.post('/webhooks/listingsapi', async (req, res) => {const header = req.get('X-ListingsAPI-Signature');if (!signatureIsValid(req.rawBody, header)) { return res.status(401).send('bad signature');} const body = req.body; // The verification handshake: answer with HEX, not base64.if (body.event === 'endpoint.verification') { const answer = crypto .createHmac('sha256', SECRET) .update(body.nonce) .digest('hex'); return res.status(200).send(answer);} // A real event: durable write first, acknowledge second.try { await storeForProcessing(req.rawBody, header);} catch (err) { // Nothing was persisted, so do not claim success. return res.status(500).send('could not persist');}return res.status(200).json({ received: true });});import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() HEADER = "X-ListingsAPI-Signature" 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) def store_for_processing(raw_body: bytes, signature: str) -> None: """The only work allowed before the 200: put the raw bytes somewhere durable and return only once the write is committed. Swap the INSERT for your real queue or table write - nothing is retried if the process dies after the response goes out.""" with db.begin() as tx: tx.execute( "INSERT INTO webhook_inbox (signature, body) VALUES (%s, %s)", (signature, raw_body), ) @app.post("/webhooks/listingsapi")def webhooks(): raw = request.get_data() # raw bytes, exactly as received header = request.headers.get(HEADER) if not signature_is_valid(raw, header): abort(401) body = request.get_json(silent=True) or {} # The verification handshake: answer with HEX, not base64. if body.get("event") == "endpoint.verification": nonce = body["nonce"].encode() return hmac.new(SECRET, nonce, hashlib.sha256).hexdigest(), 200 # A real event: durable write first, acknowledge second. try: store_for_processing(raw, header or "") except Exception: # Nothing was persisted, so do not claim success. return {"error": "could not persist"}, 500 return {"received": True}, 200Test vectors
Fixed inputs, and the exact outputs a correct implementation produces. Wire these into a unit test before you point a real delivery at your endpoint.
Secret (obviously fake, for tests only):
whsec_test_0123456789abcdef0123456789abcdefRaw request body, exactly these bytes:
{"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"}}Expected request signature, base64, sha256= prefixed:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=For the verification challenge, with the same secret and this nonce:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cyour endpoint must reply 200 with exactly this lowercase hex body:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Reproduce both — save the body line above to body.json with no trailing
newline (printf '%s' '...' > body.json), then:
import base64, hashlib, hmac secret = b"whsec_test_0123456789abcdef0123456789abcdef"body = open("body.json", "rb").read() # the 209 bytes abovenonce = b"9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c" # request signature: base64, over the raw bodyprint("sha256=" + base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode())# challenge answer: lowercase hex, over the nonce aloneprint(hmac.new(secret, nonce, hashlib.sha256).hexdigest())If the first line does not match the header value above, your bytes are wrong, not your HMAC. Check the body length first.
Next
- Delivery behavior: the envelope, the 5-second budget, and every delivery status.
- Event reference: all 18 events and their payloads.