Receiving webhooks
Ce contenu n’est pas encore disponible dans votre langue.
In short
Section titled “In short”Anyone who learns your receiver’s address could send it a message that looks like it came from SpeculaPro. So every webhook carries a signature: a short code computed from the message and a secret that only SpeculaPro and your system know. Your receiver computes the same code itself; if they match, the message is genuine and wasn’t changed on the way.
A receiver must do five things:
- Check the signature, against the raw bytes it received.
- Reject old messages, so a message someone recorded can’t be replayed later.
- Ignore duplicates, because the same event can arrive more than once.
- Answer quickly, with a 2xx status, and do the real work afterwards.
- Keep the secret secret.
SpeculaPro signs according to the Standard Webhooks specification, so any library that implements it works. The examples below use only each language’s standard library, so there’s nothing extra to install or keep up to date. They were tested against messages signed by SpeculaPro’s own signing code, including during a secret rotation.
How the signature works
Section titled “How the signature works”Each request has three headers:
| Header | Example | Meaning |
|---|---|---|
webhook-id |
01a1208c-0c9a-73e4-89c9-5a8a6e1cf30a |
The event’s ID |
webhook-timestamp |
1791557494 |
When it was signed, in seconds since 1 January 1970 |
webhook-signature |
v1,5XZVYc6LFmzXOu7iGiFL83jgw7+MQUPo1tTCN/v01to= |
One or more signatures, separated by spaces |
To check it:
-
Take the secret, which starts
whsec_. Removewhsec_and base64-decode the rest: that is the key. -
Build the text that was signed: the
webhook-id, a full stop, thewebhook-timestamp, a full stop, then the raw request body, exactly as received:01a1208c-0c9a-73e4-89c9-5a8a6e1cf30a.1791557494.{"id": "01a1208c-…", …} -
Compute HMAC-SHA256 of that text with the key, and base64-encode the result.
-
Split
webhook-signatureon spaces. Each part looks likev1,<base64>. The message is genuine if anyv1part equals your result. -
Compare in constant time (see below), and check the timestamp is within 5 minutes of now.
Examples
Section titled “Examples”Each example is a complete small receiver. It reads the secret from the environment variable
SPECULAPRO_WEBHOOK_SECRET (and, optionally, SPECULAPRO_WEBHOOK_SECRET_PREVIOUS), listens for
POST /speculapro, checks the message, answers 204 and processes the event afterwards. Adapt the
last part to your system.
Go 1.22 or later; standard library only.
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "io" "log" "net/http" "os" "strconv" "strings" "sync" "time")
// Reject requests signed more than 5 minutes before or after now.const tolerance = 5 * time.Minute
// verify checks a SpeculaPro webhook. It returns nil if any signature in the// webhook-signature header matches any of your secrets.func verify(secrets []string, h http.Header, body []byte, now time.Time) error { id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || ts == "" || sigs == "" { return errors.New("missing webhook headers") } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("invalid webhook-timestamp") } if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance { return errors.New("webhook-timestamp too old or too new") } signed := []byte(id + "." + ts + "." + string(body)) for _, secret := range secrets { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return errors.New("secret is not a whsec_ value") } mac := hmac.New(sha256.New, key) mac.Write(signed) want := mac.Sum(nil) for _, part := range strings.Fields(sigs) { version, b64, ok := strings.Cut(part, ",") if !ok || version != "v1" { continue } got, err := base64.StdEncoding.DecodeString(b64) if err == nil && hmac.Equal(got, want) { // constant-time return nil } } } return errors.New("no valid signature")}
var ( mu sync.Mutex seen = map[string]bool{} // use a database table with a unique key in production)
func handler(secrets []string, work chan<- []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the raw bytes, before any JSON parsing if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } if err := verify(secrets, r.Header, body, time.Now()); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } id := r.Header.Get("webhook-id") mu.Lock() dup := seen[id] seen[id] = true mu.Unlock() if !dup { work <- body // process after answering } w.WriteHeader(http.StatusNoContent) }}
func main() { secrets := []string{os.Getenv("SPECULAPRO_WEBHOOK_SECRET")} if prev := os.Getenv("SPECULAPRO_WEBHOOK_SECRET_PREVIOUS"); prev != "" { secrets = append(secrets, prev) } work := make(chan []byte, 1000) go func() { for body := range work { log.Printf("event: %s", body) // your business logic here } }() http.Handle("POST /speculapro", handler(secrets, work)) log.Fatal(http.ListenAndServe(":8000", nil))}Node.js 18 or later; no packages. Save as receiver.mjs and run node receiver.mjs.
import { createServer } from "node:http";import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;const secrets = [process.env.SPECULAPRO_WEBHOOK_SECRET, process.env.SPECULAPRO_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);const seen = new Set(); // use a database table with a unique key in production
// Returns true if any signature in the webhook-signature header matches any of your secrets.function verify(headers, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) { const id = headers["webhook-id"]; const ts = headers["webhook-timestamp"]; const sigs = headers["webhook-signature"]; if (!id || !ts || !sigs) return false; const sent = Number(ts); if (!Number.isInteger(sent) || Math.abs(nowSeconds - sent) > TOLERANCE_SECONDS) return false; const signed = Buffer.concat([Buffer.from(`${id}.${ts}.`), rawBody]); for (const secret of secrets) { const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const want = createHmac("sha256", key).update(signed).digest(); for (const part of sigs.split(" ")) { const [version, b64] = part.split(","); if (version !== "v1" || !b64) continue; const got = Buffer.from(b64, "base64"); if (got.length === want.length && timingSafeEqual(got, want)) return true; // constant-time } } return false;}
createServer((req, res) => { if (req.method !== "POST" || req.url !== "/speculapro") { res.writeHead(404).end(); return; } const chunks = []; let size = 0; req.on("data", (c) => { size += c.length; if (size > 1 << 20) req.destroy(); else chunks.push(c); }); req.on("end", () => { const rawBody = Buffer.concat(chunks); // the raw bytes, before any JSON parsing if (!verify(req.headers, rawBody)) { res.writeHead(401).end("invalid signature"); return; } const id = req.headers["webhook-id"]; res.writeHead(204).end(); // answer first, then process if (seen.has(id)) return; seen.add(id); setImmediate(() => { const event = JSON.parse(rawBody); console.log("event:", event.type, event.data); // your business logic here }); });}).listen(8001);Python 3.9 or later; standard library only.
import base64import hashlibimport hmacimport jsonimport osimport queueimport threadingimport timefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOLERANCE_SECONDS = 5 * 60SECRETS = [s for s in (os.environ.get("SPECULAPRO_WEBHOOK_SECRET"), os.environ.get("SPECULAPRO_WEBHOOK_SECRET_PREVIOUS")) if s]seen = set() # use a database table with a unique key in productionseen_lock = threading.Lock()work = queue.Queue()
def verify(headers, raw_body, now=None): """True if any signature in webhook-signature matches any of your secrets.""" msg_id = headers.get("webhook-id") ts = headers.get("webhook-timestamp") sigs = headers.get("webhook-signature") if not (msg_id and ts and sigs): return False try: sent = int(ts) except ValueError: return False if abs((now or time.time()) - sent) > TOLERANCE_SECONDS: return False signed = f"{msg_id}.{ts}.".encode() + raw_body for secret in SECRETS: key = base64.b64decode(secret.removeprefix("whsec_")) want = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for part in sigs.split(" "): version, _, b64 = part.partition(",") if version == "v1" and hmac.compare_digest(b64, want): # constant-time return True return False
class Receiver(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/speculapro": self.send_error(404) return length = int(self.headers.get("Content-Length", 0)) if length > 1 << 20: self.send_error(413) return raw_body = self.rfile.read(length) # the raw bytes, before any JSON parsing if not verify(self.headers, raw_body): self.send_error(401, "invalid signature") return msg_id = self.headers["webhook-id"] with seen_lock: duplicate = msg_id in seen seen.add(msg_id) self.send_response(204) # answer first, then process self.end_headers() if not duplicate: work.put(raw_body)
def worker(): while True: event = json.loads(work.get()) print("event:", event["type"], event["data"]) # your business logic here
threading.Thread(target=worker, daemon=True).start()ThreadingHTTPServer(("", 8002), Receiver).serve_forever()Check the raw body
Section titled “Check the raw body”The signature covers the body byte for byte. If your framework parses the JSON and you
re-serialise it, the spacing or field order changes and the signature no longer matches, even though
the content is the same. Read the raw body first, check it, and only then parse it. In
Express, for example, use express.raw({ type: "application/json" }) on the webhook route instead of
express.json().
SpeculaPro may send fields in a different order, or with different spacing, from the examples in the Webhooks API. That doesn’t matter as long as you check the bytes you received.
Compare in constant time
Section titled “Compare in constant time”An ordinary string comparison stops at the first character that differs, so it takes slightly longer the more characters match. An attacker sending many guesses can measure that and work out a valid signature bit by bit. Constant-time comparison always takes the same time. Use your language’s:
| Language | Function |
|---|---|
| Go | hmac.Equal |
| Node.js | crypto.timingSafeEqual (both buffers must be the same length, so check that first) |
| Python | hmac.compare_digest |
Reject old messages
Section titled “Reject old messages”Each attempt is signed with the time it was sent. Reject any message whose webhook-timestamp is
more than 5 minutes before or after your clock. That stops someone who recorded a genuine message
from replaying it later, because the timestamp is part of what’s signed and can’t be changed. Keep
your receiver’s clock synchronised (NTP).
Retries and resends are signed again at the time they’re sent, so they pass this check.
Ignore duplicates
Section titled “Ignore duplicates”The same event can reach you more than once: when your receiver accepted it but its answer was lost
or late, when an administrator selects Resend, or, rarely, when a SpeculaPro worker restarts mid
delivery. Every copy has the same webhook-id.
Record each webhook-id you have processed, for example in a database table with a unique key, and
skip any you have seen. Keep them for at least as long as SpeculaPro retries (about 28 hours); a few
days is safer.
Events can also arrive out of order, because one can be retried while a later one goes straight
through. Don’t let an older event overwrite newer information: compare created_at, or fetch the
current state from SpeculaPro.
Answer quickly
Section titled “Answer quickly”SpeculaPro waits 10 seconds for an answer, then counts the attempt as failed and retries. Answer
with 200, 202 or 204 as soon as the signature checks out and the event is safely recorded, then
do the real work (opening a ticket, calling your core banking system) afterwards, from a queue.
| Answer | SpeculaPro does |
|---|---|
| Any 2xx | Counts it as delivered |
| 3xx | Counts it as failed: redirects aren’t followed. Use the final address |
| 4xx or 5xx | Counts it as failed and retries, on the schedule |
| Nothing within 10 seconds | Counts it as failed and retries |
Answer 401 to a message that fails the checks. SpeculaPro will retry it, which is harmless; a real
message never fails them unless the secret is wrong. If your receiver keeps rejecting genuine
messages, check that it has the current secret.
Keep error responses short and free of secrets: the first 200 characters of your answer are shown in the delivery log, where administrators can read them.
When the secret is rotated
Section titled “When the secret is rotated”When an administrator rotates the secret,
SpeculaPro signs every message with both the new and the old secret for an overlap period (24
hours from the screen), so webhook-signature has two parts:
v1,5XZVYc6LFmzXOu7iGiFL83jgw7+MQUPo1tTCN/v01to= v1,p9nLEXT+aNhDZz9RjoZRza3kF7LBWATjRCf6kDyKB3Y=Your receiver accepts a message if any signature matches any secret it holds, so:
- During the overlap, your receiver keeps working with the old secret.
- Put the new secret in place, as
SPECULAPRO_WEBHOOK_SECRETin the examples, before the overlap ends. - If you can’t switch every receiver instance at once, keep the old one as
SPECULAPRO_WEBHOOK_SECRET_PREVIOUSuntil they have all switched, then remove it.
Keep the secret secret
Section titled “Keep the secret secret”- Store it in your secret manager, never in source code or a ticket.
- Use one secret per endpoint, so a leak affects one system.
- If it might have leaked, rotate it with no overlap. See Rotate the signing secret.
Testing your receiver
Section titled “Testing your receiver”- Deploy the receiver and give it the secret.
- In Settings → Webhooks, select the endpoint and then Send test event.
- Check Recent deliveries: a green tick and HTTP 204 means it worked. A red cross shows your receiver’s answer or the connection error.
The test event’s type is webhook.test. Make sure your receiver accepts it (answers 2xx) even
though it doesn’t act on it.