Webhooks
How to make sure a request really comes from AOA: the HMAC-SHA256 AOA-Signature, a 5-minute time window, deduplication and examples in Node.js and Python.
Your handler URL will become known sooner or later: it shows up in proxy logs, browser history, screenshots. The signature is the only thing that tells our request apart from any other. Always verify it, before you even parse the body.
AOA-Signature: t=1800000000,v1=5f2a9c...64-char-hext is the signing time in seconds, v1 is an HMAC-SHA256 of the string {t}.{body} with your secret. The scheme matches Stripe’s, so ready-made examples from there work almost unchanged.
timingSafeEqual instead of ===: differences in response time let an attacker guess the signature character by character.import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyAoaSignature(header, rawBody, secret) {
const parts = header.split(',');
const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2);
const signature = parts.find((p) => p.startsWith('v1='))?.slice(3);
if (!timestamp || !signature) return false;
// Protects against replaying an old request.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}// The body must be RAW: express.json() changes the string and the signature won't match.
app.post(
'/hooks/aoa',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
if (!verifyAoaSignature(req.get('AOA-Signature'), raw, process.env.AOA_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
// Respond right away, do the heavy work after the response.
res.status(200).end();
void handleEvent(event);
},
);import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify_aoa_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)One event can arrive twice. The typical case: you processed the request, but the response didn’t reach us in time, so we retried. The AOA-Delivery-Id header stays the same on a retry, so store the identifiers you’ve processed and skip the ones you’ve seen.
You don’t need to keep them forever: retries last about nine hours, so keeping them for a day covers every retry with room to spare.
Delete the URL and register it again: a new secret is issued on creation. The old one stops working immediately, together with the deleted URL.
We don’t follow redirects
A signed request doesn’t follow3xx: otherwise a changed redirect on your host would forward the signature and the buyer’s data to someone else’s server. Give the final URL straight away.