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:
| Purpose | Direction | Encoding | |
|---|---|---|---|
| Request signature | Prove each delivery is genuine | Sent to you, in a header | base64 |
| Verification challenge | Prove once that you hold the secret | You send it back, in the body | hex |
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.