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:

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:

{
  "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 base64
import hashlib
import hmac
import 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