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 =.
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.
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);} app.post('/webhooks/listingsapi', (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. Hand off and acknowledge inside 5 seconds.enqueue(body);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) @app.post("/webhooks/listingsapi")def webhooks(): raw = request.get_data() # raw bytes, exactly as received if not signature_is_valid(raw, request.headers.get(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. Hand off and acknowledge inside 5 seconds. enqueue(body) return {"received": True}, 200Next
- Delivery behavior: the envelope, the 5-second budget, and every delivery status.
- Event reference: all 18 events and their payloads.