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 statusCallback and eventCallback to 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 later
import 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.

ControlHow
HTTPS onlyServe the callback URL over TLS and reject plain HTTP.
Secret tokenEmbed 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 controlRoutee 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 responsesReturn 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 200 and 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


Related