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 the webhook-id header (= the body's top-level id).
  • webhook-timestamp — the value of the webhook-timestamp header (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

  1. 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.
  2. Use a constant-time string compare. Avoid == on the signature value; use your language's HMAC-equality helper (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, subtle.ConstantTimeCompare in Go, Rack::Utils.secure_compare in Ruby).
  3. 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.
  4. Iterate over every signature value. During a key rotation Listo may send multiple space-separated v1,… values. Match against any.
  5. Dedupe on webhook-id. Even after signature passes, the same webhook-id may arrive multiple times because of retries. Persist a record keyed by webhook-id and 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 the whk_… key bytes verbatim into the HMAC, not base64-decoded. The default 'base64' mode in standardwebhooks would 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}, 200

Go (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 defence

Common 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 200 when verification fails. Always return 4xx so Listo's audit trail records the failure clearly.

Where to next