Verifying Webhook Signatures
Every Listo webhook is signed using the Standard Webhooks v1 signature scheme: HMAC-SHA256 over id.timestamp.body, base64-encoded. Verifying the signature is mandatory — an unauthenticated handler is an SSRF gateway into your infrastructure. The simplest correct implementation is to drop in the official standardwebhooks library for your language. The snippets below show idiomatic usage in Node, Python, Go, and Ruby, plus a from-scratch fallback if your language has no official library.
What gets signed
Three pieces of input feed the HMAC, joined with literal full-stops (.):
to_sign = webhook-id + "." + webhook-timestamp + "." + raw_body
signature = base64( HMAC-SHA256( signing_key, to_sign ) )webhook-id— the value of thewebhook-idheader (= the body's top-levelid).webhook-timestamp— the value of thewebhook-timestampheader (Unix epoch seconds as a string).raw_body— the raw bytes of the request body, exactly as transmitted. Re-stringifying the JSON, pretty-printing, or re-encoding characters all change the bytes and break verification.
The result is sent in the webhook-signature header as v1,<base64>. A key rotation in flight may produce multiple values separated by spaces:
webhook-signature: v1,Aaaaa== v1,Bbbbb==Your verifier must accept the request if any of the values matches.
Five rules every verifier must follow
- Verify against the raw bytes. Capture the body before any JSON parser touches it.
express.json(),body-parser, FastAPI's automatic parsing — all of them re-serialize and break byte parity. Use a raw body parser and feed those bytes to the verifier. - Use a constant-time string compare. Avoid
==on the signature value; use your language's HMAC-equality helper (crypto.timingSafeEqualin Node,hmac.compare_digestin Python,subtle.ConstantTimeComparein Go,Rack::Utils.secure_comparein Ruby). - Enforce the 5-minute timestamp window. Reject requests where
abs(now_unix_seconds - int(webhook-timestamp)) > 300. This is the replay-window guard mandated by Standard Webhooks. The official libraries enforce it for you — don't disable it. - Iterate over every signature value. During a key rotation Listo may send multiple space-separated
v1,…values. Match against any. - Dedupe on
webhook-id. Even after signature passes, the samewebhook-idmay arrive multiple times because of retries. Persist a record keyed bywebhook-idand short-circuit on subsequent arrivals (200 OK, do nothing).
Node.js / TypeScript (Express)
Use standardwebhooks.
import { Webhook } from 'standardwebhooks';
import express from 'express';
const wh = new Webhook(process.env.LISTO_WEBHOOK_SIGNING_KEY!, { format: 'raw' });
const app = express();
// Capture the raw body. JSON parsing happens AFTER verification.
app.post(
'/listo/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
let event;
try {
event = wh.verify(req.body, {
'webhook-id': req.header('webhook-id')!,
'webhook-timestamp': req.header('webhook-timestamp')!,
'webhook-signature': req.header('webhook-signature')!,
});
} catch (err) {
return res.status(400).json({ ok: false });
}
handleEvent(event); // safe — signature, timestamp, and body match
res.status(200).json({ ok: true });
},
);🛈 Why
{ format: 'raw' }? Listo's signing path passes thewhk_…key bytes verbatim into the HMAC, not base64-decoded. The default'base64'mode instandardwebhookswould decode the key first — that breaks signature parity. This option lines the verifier up with what the producer actually does.
Python (Flask)
Use standardwebhooks.
import os
from standardwebhooks import Webhook
from flask import Flask, request
wh = Webhook(os.environ["LISTO_WEBHOOK_SIGNING_KEY"])
app = Flask(__name__)
@app.post("/listo/webhook")
def listo_webhook():
try:
# Pass raw bytes — not request.get_json() and not request.json.
event = wh.verify(request.get_data(), dict(request.headers))
except Exception:
return {"ok": False}, 400
handle_event(event)
return {"ok": True}, 200Go (net/http)
net/http)Use github.com/standard-webhooks/standard-webhooks/libraries/go.
package main
import (
"io"
"net/http"
"os"
"github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func main() {
wh, err := standardwebhooks.NewWebhook(os.Getenv("LISTO_WEBHOOK_SIGNING_KEY"))
if err != nil {
panic(err)
}
http.HandleFunc("/listo/webhook", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if err := wh.Verify(body, r.Header); err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
handleEvent(body) // body is the raw JSON of the DomainEvent envelope
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
_ = http.ListenAndServe(":3000", nil)
}Ruby (Rack / Sinatra)
Use standardwebhooks.
require 'sinatra'
require 'standardwebhooks'
WH = StandardWebhooks::Webhook.new(ENV.fetch('LISTO_WEBHOOK_SIGNING_KEY'))
post '/listo/webhook' do
body = request.body.read
begin
event = WH.verify(body, request.env.select { |k, _| k.start_with?('HTTP_WEBHOOK_') })
rescue StandardError
halt 400, { ok: false }.to_json
end
handle_event(event)
status 200
{ ok: true }.to_json
end(Rack converts header names to HTTP_WEBHOOK_ID etc. The standardwebhooks gem accepts that shape directly.)
Manual verification (any language)
If your language has no official library, the algorithm is short enough to implement directly.
1. raw_body = request body bytes (do not re-encode)
2. signed_string = webhook-id + "." + webhook-timestamp + "." + raw_body
3. expected = base64( HMAC-SHA256( signing_key, signed_string ) )
4. for each space-separated value v in webhook-signature:
if v starts with "v1,":
if constant_time_equals(v[3:], expected): return PASS
return FAIL
5. assert abs(now_unix_seconds - int(webhook-timestamp)) <= 300
6. assert webhook-id not in recent_ids cache # optional dedupe defenceCommon pitfalls:
- Trimming/normalising the body. Don't. Sign the bytes as received.
- Decoding the key. The
whk_…value goes into the HMAC verbatim. Do not base64-decode it before use. - Treating the timestamp as milliseconds. It is seconds.
- Comparing with
==. Use a constant-time compare. - Falling back to silent
200when verification fails. Always return4xxso Listo's audit trail records the failure clearly.
Where to next
Updated 1 day ago
