A webhook receiver is just an HTTP endpoint that accepts POST. The interesting bits are not the framework code — they are the order of operations: verify signature first, parse JSON second, dedupe third, enqueue fourth, respond fifth, do work last.
The standard handler shape
- Read the raw request body as bytes (not parsed JSON). HMAC is computed over the bytes; if you parse and re-serialise first, the signature check will fail because formatting differs.
- Verify the HMAC signature against the body bytes using your shared secret. If invalid, return 401 immediately.
- Verify the timestamp is recent (within last few minutes). If too old, reject — this prevents replay attacks where someone captures an old webhook and resends it.
- Parse the JSON body now that you trust it.
- Check the event ID against your processed-events store. If you have already seen this ID, return 200 immediately without re-processing.
- Enqueue the event for asynchronous processing.
- Mark the event ID as processed.
- Return 200 as quickly as possible.
Working Node.js example (Express)
Node.js webhook receiver with HMAC verification
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.TEAMCONNECT_WEBHOOK_SECRET;
// IMPORTANT: capture raw body bytes for HMAC verification
app.use('/webhooks/team-connect',
express.raw({ type: 'application/json' })
);
app.post('/webhooks/team-connect', async (req, res) => {
const rawBody = req.body; // Buffer because of express.raw
// 1. Signature check
const signatureHeader = req.get('X-TeamConnect-Signature') || '';
const signature = signatureHeader.replace('sha256=', '');
const expected = crypto
.createHmac('sha256', SECRET)
.update(rawBody)
.digest('hex');
// Timing-safe compare to prevent timing attacks
if (!crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
)) {
return res.status(401).send('Bad signature');
}
// 2. Timestamp check (replay protection)
const ts = parseInt(req.get('X-TeamConnect-Timestamp') || '0', 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > 300) { // 5 minute window
return res.status(401).send('Stale timestamp');
}
// 3. Parse JSON now that we trust the body
const event = JSON.parse(rawBody.toString('utf8'));
// 4. Idempotency check
if (await alreadyProcessed(event.id)) {
return res.status(200).send('Already processed');
}
// 5. Enqueue for async processing
await queue.publish('team-connect-events', event);
// 6. Mark as processed and respond
await markProcessed(event.id);
res.status(200).send('OK');
});
app.listen(3000);
Working Python example (FastAPI)
Python webhook receiver with HMAC verification
import hmac
import hashlib
import time
import os
from fastapi import FastAPI, Request, HTTPException, Header
app = FastAPI()
SECRET = os.environ["TEAMCONNECT_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/team-connect")
async def handle_webhook(
request: Request,
x_teamconnect_signature: str = Header(...),
x_teamconnect_timestamp: str = Header(...),
):
raw_body = await request.body() # raw bytes, not parsed JSON
# 1. Signature verification
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
received = x_teamconnect_signature.removeprefix("sha256=")
if not hmac.compare_digest(expected, received):
raise HTTPException(401, "Bad signature")
# 2. Timestamp / replay protection
try:
ts = int(x_teamconnect_timestamp)
except ValueError:
raise HTTPException(401, "Bad timestamp")
if abs(time.time() - ts) > 300:
raise HTTPException(401, "Stale timestamp")
# 3. Parse JSON only now
import json
event = json.loads(raw_body.decode("utf-8"))
# 4. Idempotency
if await already_processed(event["id"]):
return {"status": "already_processed"}
# 5. Enqueue async work
await queue.publish("team-connect-events", event)
# 6. Mark + respond
await mark_processed(event["id"])
return {"status": "ok"}
Why "raw body" matters so much
Most web frameworks helpfully parse JSON into a dictionary or object before your handler sees it. This is wrong for webhook handlers, because HMAC is computed over the exact byte sequence the sender sent. If your framework re-serialises the parsed JSON to verify the HMAC, the formatting will differ (key ordering, whitespace, decimal precision) and the signature check will fail. Always capture the raw body bytes before any parsing or middleware touches them.
In Express this means express.raw({ type: 'application/json' }) mounted at the webhook path before any JSON middleware. In FastAPI it means await request.body() before accessing request.json(). In Flask it means request.get_data(). In .NET it means reading the request stream before MVC binds the model.
The framework gotcha that breaks webhooks: body-parsing middleware mounted globally will silently consume the request body before your webhook handler runs. Always mount your raw-body middleware before any global JSON parser, and scope it to the webhook path only. The number of webhook integrations that fail signature verification because of a misordered Express middleware stack is staggering.