Webhooks
The POST /emails response only tells you that the message was handed off for delivery. The final outcome — landed in the inbox, bounced, marked as spam, opened, clicked — arrives later via webhooks.
A webhook is a URL on your server. Runsite POSTs a JSON event to it whenever a state change happens for one of your messages, and signs every request so you can be sure the call is genuine.
Subscribing
Section titled “Subscribing”- Open your email service → Webhooks tab → Add Webhook.
- Enter the URL Runsite should call (must be publicly reachable HTTPS).
- Pick which events you want — at least one.
- Confirm.
The dashboard returns a signing secret that looks like:
whsec_2b1d9a4f6c8e0a3b5d7f1c4e6a8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4Copy it — same one-time-display rule as API keys. You’ll need it to verify signatures in your webhook handler.
Events
Section titled “Events”| Event | When it fires |
|---|---|
delivered | Receiving server accepted the message and put it in the recipient’s mailbox queue. |
bounced | Receiving server rejected the message — hard bounce (address invalid) or soft bounce (mailbox full, server temporarily down, message too big). |
complained | Recipient marked the message as spam. |
opened | Recipient opened the email (requires open-tracking pixel — enabled by default). |
clicked | Recipient clicked a link Runsite tracked. |
You can subscribe to any subset — most apps only care about delivered, bounced and complained.
Payload shape
Section titled “Payload shape”{ "event": "delivered", "email_id": "9f1c47e0-4e8e-4d6d-9a92-1e2e3f4d5b6c", "service_id": "b7e3a2d1-…", "to": "user@example.com", "from": "support@yourcompany.com", "subject": "Welcome", "timestamp": "2026-05-01T10:42:18Z", "data": { "smtp_response": "250 2.0.0 OK" }}data is event-specific:
bouncedincludesbounce_type(hard/soft) andreason.complainedincludes the feedback report source.openedincludesuser_agentandip.clickedincludesurl(the link the recipient followed).
Runsite delivers each event at least once — be ready for occasional duplicates. Use email_id + event + timestamp to deduplicate if exact-once matters.
Verifying the signature
Section titled “Verifying the signature”Every request includes two extra headers:
X-Runsite-Timestamp: 1714564938X-Runsite-Signature: hex(HMAC-SHA256(secret, "{timestamp}.{body}"))The verification recipe:
- Read
X-Runsite-TimestampandX-Runsite-Signature. - Reject the request if the timestamp is more than ~5 minutes old (replay protection).
- Build the signed input:
"<timestamp>.<raw request body as bytes>". - Compute
hex(HMAC-SHA256(secret, input)). - Compare against
X-Runsite-Signatureusing a constant-time comparison. - Only accept the payload if they match.
Always verify against the raw bytes of the request body, not after a JSON round-trip — re-serialization can change whitespace and break the signature.
Python — hmac + Flask
Section titled “Python — hmac + Flask”import hmacimport hashlibimport osimport timefrom flask import Flask, request, abort
WEBHOOK_SECRET = os.environ["RUNSITE_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/email-webhook")def email_webhook(): timestamp = request.headers.get("X-Runsite-Timestamp", "") received_sig = request.headers.get("X-Runsite-Signature", "") raw_body = request.get_data(as_text=False)
if abs(int(time.time()) - int(timestamp)) > 300: abort(400, "stale timestamp")
expected = hmac.new( WEBHOOK_SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest()
if not hmac.compare_digest(expected, received_sig): abort(401, "bad signature")
payload = request.get_json() handle_event(payload) return "", 204Node.js — express + crypto
Section titled “Node.js — express + crypto”import crypto from "node:crypto";import express from "express";
const app = express();const SECRET = process.env.RUNSITE_WEBHOOK_SECRET;
app.post( "/email-webhook", express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.headers["x-runsite-timestamp"]; const received = req.headers["x-runsite-signature"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(400).send("stale timestamp"); }
const expected = crypto .createHmac("sha256", SECRET) .update(`${timestamp}.`) .update(req.body) .digest("hex");
const ok = expected.length === received.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8")); handleEvent(event); res.sendStatus(204); },);Responding to events
Section titled “Responding to events”- Reply with
2xxwithin ~10 seconds. The body is ignored. - Reply with
4xxto refuse and not be retried (use only for bad/unauthorized events you’ve already verified can’t be processed). - Reply with
5xxto ask Runsite to retry. Retries follow exponential backoff over ~24 hours; after that the event is dropped.
Heavy work (DB writes, downstream API calls) should be enqueued — return 2xx quickly and process asynchronously.
What to do with each event
Section titled “What to do with each event”A typical mapping:
| Event | App reaction |
|---|---|
delivered | Mark message as delivered in your DB. Optional. |
bounced (hard) | Add the recipient to your suppression list — don’t email them again. Surface “invalid email” in the user’s account UI. |
bounced (soft) | Retry later or downgrade the address after multiple soft bounces. |
complained | Suppress the recipient permanently. Possibly notify your support team. |
opened / clicked | Engagement metrics, A/B testing, lifecycle triggers. |
Local development
Section titled “Local development”Webhook deliveries need a publicly reachable URL. For local dev, point Runsite at a tunnel (ngrok, cloudflared tunnel, tailscale funnel) and update the URL once you ship to production.
You can also send a test event from the dashboard — useful for confirming your handler accepts the payload shape and signature before any real traffic flows.
Always verify the signature
Anyone who can reach your webhook URL can POST anything to it. Without signature verification an attacker can mark messages as bounced, suppress legitimate users, or pollute your stats. Treat unsigned or invalid payloads exactly like an unauthenticated request — reject with 401.