Firme e verifica
Verifica l'header X-ListingsAPI-Signature, rispondi alla richiesta di verifica dell'endpoint ed evita di confondere base64 e hex.
L'URL dei tuoi webhook è un endpoint HTTPS pubblico, quindi chiunque su internet può inviarvi richieste POST. Il segreto di firma ti permette di distinguere una consegna autentica da una contraffatta: ogni richiesta firmata contiene un HMAC calcolato con un segreto noto solo a te e alla piattaforma.
Due meccanismi distinti usano questo segreto ed è facile confonderli:
L'header della firma
Ogni consegna firmata contiene:
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )Il prefisso sha256= fa parte del valore dell'header. Includilo quando costruisci la stringa attesa oppure confronta solo la parte dopo =. Non viene anteposto nulla al corpo prima di calcolarne l'hash: nessun timestamp, nessuna versione, nessuna stringa di firma concatenata. L'HMAC viene calcolato esclusivamente sul corpo grezzo.
Verifica passo per passo
- Leggi i byte grezzi del corpo della richiesta, esattamente come li hai ricevuti. Non analizzare il JSON per poi serializzarlo di nuovo. L'ordine delle chiavi, gli spazi e le sequenze di escape Unicode modificano i byte, e qualsiasi modifica invalida l'HMAC. La maggior parte dei framework richiede una configurazione esplicita per conservare il corpo grezzo. Consulta gli esempi di seguito.
- Calcola
HMAC-SHA256(your_secret, raw_body), codificalo in base64 e anteponisha256=. - Confronta il risultato con l'header usando un confronto a tempo costante (
crypto.timingSafeEqual,hmac.compare_digest). Un semplice==rivela, attraverso i tempi di esecuzione, quanti byte iniziali corrispondono. - Se non corrispondono, restituisci
401e non elaborare nulla.
Verifica dell'endpoint
Prima che inizi l'invio degli eventi effettivi, il tuo endpoint deve dimostrare, una volta, di possedere il segreto. Questo impedisce che un flusso di payload firmati venga inviato a un URL digitato in modo errato o appartenente a qualcuno che non è in grado di convalidarli.
Avvii la verifica dalla pagina Webhooks della dashboard. La piattaforma invia una challenge firmata e attende la risposta per un massimo di 10 secondi. (Questi 10 secondi si applicano solo all'handshake; per le consegne effettive il timeout è di 5 secondi.)
Il contratto del challenge
Il challenge è una normale richiesta firmata, con un nome di evento riservato:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Il tuo endpoint deve:
- Verificare
X-ListingsAPI-Signaturecome per qualsiasi consegna. - Riconoscere
event == "endpoint.verification"e seguire un percorso dedicato prima della normale gestione degli eventi. - Calcolare
HMAC-SHA256(secret, nonce)come stringa esadecimale minuscola. L'HMAC viene calcolato solo sul nonce, non sul corpo della richiesta. - Rispondere con
200usando quella stringa esadecimale come corpo della risposta. È prevista una semplice stringa esadecimale; viene accettato anche un valore esadecimale tra virgolette o rappresentato come stringa JSON.
Qualsiasi altro risultato (uno stato diverso da 2xx, un digest errato, un timeout, un host irraggiungibile) fa fallire la verifica.
Cosa annulla la verifica
La verifica non è permanente. L'account torna allo stato non verificato e le consegne vengono sospese ogni volta che:
- Tu modifichi l'URL dei webhook o rigeneri il segreto. La prova precedente non è più valida per la nuova configurazione.
- Un tentativo di verifica fallisce, anche se l'endpoint funzionava in precedenza e da allora ha smesso di essere risolvibile o di rispondere. Ripetere la verifica su un endpoint non funzionante disattiva quindi le consegne; questo comportamento è intenzionale.
Ripeti la verifica quando l'endpoint torna a funzionare per riprendere le consegne.
Ricevitori funzionanti
Entrambi gli esempi svolgono tutte e tre le operazioni: conservano il corpo originale, verificano la firma in tempo costante e rispondono al challenge di verifica. Entrambi leggono un solo segreto dall'ambiente: esiste esattamente un segreto valido per account e non è previsto un intervallo in cui entrambi i segreti siano validi, quindi non creare un ciclo che accetti uno qualsiasi di due segreti.
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}, 200Vettori di test
Input fissi e output esatti prodotti da un'implementazione corretta. Inseriscili in un test unitario prima di indirizzare una consegna reale al tuo endpoint.
Segreto (ovviamente fittizio, solo per i test):
whsec_test_0123456789abcdef0123456789abcdefCorpo originale della richiesta, composto esattamente da questi byte:
{"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"}}Firma della richiesta attesa, in base64, con prefisso sha256=:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Per il challenge di verifica, con lo stesso segreto e questo nonce:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cil tuo endpoint deve rispondere con 200 usando esattamente questo corpo esadecimale minuscolo:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Per riprodurre entrambi, salva la riga del corpo riportata sopra in body.json senza un carattere di fine riga finale (printf '%s' '...' > body.json), quindi:
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())Se la prima riga non corrisponde al valore dell'header riportato sopra, sono errati i byte, non l'HMAC. Controlla prima la lunghezza del corpo.
Avanti
- Comportamento di consegna: il payload standard, il limite di 5 secondi e tutti gli stati di consegna.
- Riferimento degli eventi: tutti i 18 eventi e i relativi payload.