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:

PurposeDirectionEncoding
Request signatureProve each delivery is genuineSent to you, in a headerbase64
Verification challengeProve once that you hold the secretYou send it back, in the bodyhex

The signature header

Every signed delivery carries:

Text
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

  1. 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.
  2. Compute HMAC-SHA256(your_secret, raw_body), base64-encode it, and prefix sha256=.
  3. 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.
  4. On mismatch, return 401 and process nothing.
The receiver-side check. Everything hinges on step two: the HMAC is computed over the bytes you received, not over anything you re-encoded.

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 handshake. Note the asymmetry: the challenge is signed with base64 in a header, and your answer is a hex digest in the response body.

The challenge contract

The challenge is an ordinary signed request, with a reserved event name:

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

Your endpoint must:

  1. Verify X-ListingsAPI-Signature as it would for any delivery.
  2. Recognize event == "endpoint.verification" and branch before your normal event handling.
  3. Compute HMAC-SHA256(secret, nonce) as a lowercase hex string. The HMAC is over the nonce alone, not over the request body.
  4. Reply 200 with 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.

Node.js (Express)
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 });});
Python (Flask)
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}, 200

Next