Build & secure a webhook receiver
Receive callbacks safely: validate, ack fast, dedupe, secure.
Stand up an HTTPS endpoint that receives Routee delivery and engagement callbacks, acknowledges them fast, and processes them safely.
Prerequisite: Receive transactional email delivery callbacks — how to attach
statusCallbackandeventCallbackto a send.
1. Point Routee at your endpoint
Callbacks are configured per send in the callback object. Routee POSTs JSON asynchronously to the HTTPS URLs you supply:
{
"from": { "address": "[email protected]" },
"to": [{ "address": "[email protected]" }],
"subject": "Order confirmed",
"content": { "html": "<p>Thank you for your order.</p>" },
"callback": {
"statusCallback": {
"strategy": "OnChange",
"url": "https://partner.example/webhooks/email?token=LONG_RANDOM_SECRET"
},
"eventCallback": {
"onOpen": "https://partner.example/webhooks/email/open?token=LONG_RANDOM_SECRET",
"onClick": "https://partner.example/webhooks/email/click?token=LONG_RANDOM_SECRET"
}
}
}See Callback object explained for every field.
2. Acknowledge fast, process asynchronously
Return a 2xx within about 2 seconds, then hand the payload to a queue or background worker. Do not run database writes or downstream API calls before responding — slow endpoints get retried and back up your delivery pipeline.
from flask import Flask, request, abort
import os, queue
app = Flask(__name__)
jobs = queue.Queue()
CALLBACK_TOKEN = os.environ["ROUTEE_CALLBACK_TOKEN"]
@app.post("/webhooks/email")
def email_callback():
if request.args.get("token") != CALLBACK_TOKEN:
abort(404) # hide the endpoint from probes
jobs.put(request.get_json(silent=True) or {})
return "", 200 # ack first, work laterimport express from "express";
const app = express();
app.use(express.json());
const TOKEN = process.env.ROUTEE_CALLBACK_TOKEN;
app.post("/webhooks/email", (req, res) => {
if (req.query.token !== TOKEN) return res.sendStatus(404);
res.sendStatus(200); // ack first
jobs.push(req.body); // process in the background
});3. Secure the endpoint
Routee callbacks are not signed, so protect the receiver with layered controls you own.
There is no signature header on Routee callbacks. Do not build an HMAC/signature check against one — validate a secret you control instead.
| Control | How |
|---|---|
| HTTPS only | Serve the callback URL over TLS and reject plain HTTP. |
| Secret token | Embed a long, random token in the URL path or query string and compare it on every request. Rotate by re-sending with a new URL. |
| Secret token is your primary control | Routee does not publish a fixed callback source-IP range, so treat the secret in the callback URL as the main authentication. Do not rely on IP allowlisting alone. |
| Quiet responses | Return only 2xx or 404. Never echo payload data or stack traces. |
4. Make processing idempotent
Retries and duplicate deliveries are normal. Dedupe so re-processing is harmless:
- Build a key from
trackingId+ event/status + timestamp. - Store processed keys (for example Redis with a TTL, or a unique database constraint).
- If the key already exists, ack
200and skip the work.
def dedupe_key(p):
kind = p.get("status") or p.get("event")
return f"{p.get('trackingId')}:{kind}:{p.get('timestamp')}"
def handle(p):
key = dedupe_key(p)
if seen.add_if_absent(key, ttl=86400): # False if already processed
process(p)5. Correlate with your records
Store the trackingId from the send response ({ "trackingId": "..." }) so every callback maps back to the original message. For a full delivery timeline on demand, call Get email tracking (single message timeline).
Next steps
- Handle bounces & interpret statuses — act on
BOUNCED,FAILED, andDEFERRED - Email tracking, statuses & callbacks — payload fields and lifecycle
- Status callback object explained —
statusCallbackfield reference

