Verify Webhooks
Verify HMAC signatures on Amboss Payments webhooks before applying side effects. Code samples for safe handler implementations.
Every webhook delivery is signed with the endpoint's secret. Verify the signature on your side before applying any side effects — an unverified webhook is just an HTTP POST from anyone on the internet.
Delivery headers
Each POST carries three headers:
| Header | Value |
|---|---|
x-webhook-signature | Hex-encoded HMAC-SHA256 of <timestamp>.<body> |
x-webhook-timestamp | Unix epoch in seconds, as a string (when the signature was computed) |
x-webhook-event | The event type (e.g., payment.completed) — same as event_type in the body |
The body is the raw JSON envelope shown in Webhooks → Payload shape.
Algorithm
signature = HMAC-SHA256(
key = endpoint_secret, // the whsec_… plaintext as a string
data = `${timestamp}.${body}` // body is the exact JSON bytes you received
).digest('hex')- Key: the
secretreturned once when you created (or rotated) the endpoint. Stored verbatim — don't strip thewhsec_prefix. - Message: the literal string
<timestamp>.<body>, wherebodyis the exact bytes of the HTTP request body. Don't re-serialize the parsed JSON — the producer signed the bytes on the wire, and re-stringification reorders keys. - Output: lowercase hex digest. Compare using a constant-time function.
Verify in your handler
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: capture the raw body. JSON.parse() must happen AFTER verification.
app.post(
"/webhooks/amboss",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("x-webhook-signature");
const timestamp = req.header("x-webhook-timestamp");
if (!signature || !timestamp) return res.status(400).send("missing headers");
const body = req.body.toString("utf8"); // raw bytes as string
const expected = crypto
.createHmac("sha256", process.env.AMBOSS_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
// Optional: reject deliveries older than 5 minutes to limit replay window.
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skewSeconds > 300) return res.status(400).send("timestamp out of range");
const event = JSON.parse(body);
// ...dispatch on event.event_type, dedupe on event.id...
res.sendStatus(200);
},
);import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["AMBOSS_WEBHOOK_SECRET"].encode("utf-8")
@app.post("/webhooks/amboss")
def amboss_webhook():
signature = request.headers.get("x-webhook-signature")
timestamp = request.headers.get("x-webhook-timestamp")
if not signature or not timestamp:
abort(400, "missing headers")
body = request.get_data() # raw bytes, before any JSON parsing
expected = hmac.new(
SECRET,
f"{timestamp}.".encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, "invalid signature")
if abs(time.time() - int(timestamp)) > 300:
abort(400, "timestamp out of range")
event = request.get_json()
# ...dispatch on event["event_type"], dedupe on event["id"]...
return ("", 200)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("AMBOSS_WEBHOOK_SECRET"))
func amboss(w http.ResponseWriter, r *http.Request) {
sig := r.Header.Get("x-webhook-signature")
ts := r.Header.Get("x-webhook-timestamp")
if sig == "" || ts == "" {
http.Error(w, "missing headers", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
given, err := hex.DecodeString(sig)
if err != nil || !hmac.Equal(given, mac.Sum(nil)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
n, _ := strconv.ParseInt(ts, 10, 64)
if abs(time.Now().Unix()-n) > 300 {
http.Error(w, "timestamp out of range", http.StatusBadRequest)
return
}
// ...parse body, dispatch on event_type, dedupe on id...
w.WriteHeader(http.StatusOK)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }Always use the raw request body. Most web frameworks parse JSON before your handler runs — that re-serializes the object and rearranges keys, which breaks the signature. Use the framework's "raw body" or "bytes" accessor (express.raw, Flask's request.get_data(), Go's io.ReadAll(r.Body)) and parse JSON after verifying.
Replay protection
The signed timestamp lets you reject stale or replayed requests. Compare it to your server clock and refuse anything older than a small window (5 minutes is a reasonable default). This bounds how long a captured webhook stays useful to an attacker — without it, a leaked POST body + signature could be replayed indefinitely.
The envelope id (e.g. payment.completed:tx_…) is what you dedupe on for retries — see Webhooks → Idempotency and retries.
Retry policy
Failed deliveries retry up to 8 times total, with the following backoff after each failed attempt:
| Attempt that just failed | Wait before next attempt |
|---|---|
| 1 | 10 seconds |
| 2 | 60 seconds |
| 3 – 8 | 10 minutes each |
A delivery is considered failed when:
- HTTP status is
408,429, or5xx, or - The connection errors out or exceeds the 15-second timeout.
Non-retryable 4xx responses (400, 401, 403, 404, etc.) mark the delivery as permanently failed — the most common cause is a verification mismatch on your side. Inspect responses in the dashboard.
Return 2xx only after you've persisted (or at least durably queued) the event. The producer treats 2xx as success and stops retrying.
Secret rotation
When you call rotate_secret, deliveries from that moment onward sign with the new secret. To avoid dropping in-flight retries:
- Generate the new secret, but keep the old one available in your verifier.
- Accept signatures that match either secret for a short overlap (5–15 minutes is plenty given the retry schedule above).
- After the overlap, drop the old secret.
const secrets = [process.env.AMBOSS_WEBHOOK_SECRET, process.env.AMBOSS_WEBHOOK_SECRET_OLD]
.filter(Boolean);
const valid = secrets.some(secret => {
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});Common failures
| Symptom | Likely cause |
|---|---|
| Signature never matches | You're hashing the parsed-and-re-stringified JSON instead of the raw bytes |
| Signature matches in dev, fails in prod | Body-parser middleware ran globally and consumed the raw body before your handler |
| Intermittent mismatches | Two services share the path; one of them rotated the secret without updating the other |
timestamp out of range | Server clock drift (>5 min) on your side, or you're using milliseconds instead of seconds |