Skip to content

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.

  1. Open your email service → Webhooks tab → Add Webhook.
  2. Enter the URL Runsite should call (must be publicly reachable HTTPS).
  3. Pick which events you want — at least one.
  4. Confirm.

The dashboard returns a signing secret that looks like:

whsec_2b1d9a4f6c8e0a3b5d7f1c4e6a8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4

Copy it — same one-time-display rule as API keys. You’ll need it to verify signatures in your webhook handler.

EventWhen it fires
deliveredReceiving server accepted the message and put it in the recipient’s mailbox queue.
bouncedReceiving server rejected the message — hard bounce (address invalid) or soft bounce (mailbox full, server temporarily down, message too big).
complainedRecipient marked the message as spam.
openedRecipient opened the email (requires open-tracking pixel — enabled by default).
clickedRecipient clicked a link Runsite tracked.

You can subscribe to any subset — most apps only care about delivered, bounced and complained.

{
"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:

  • bounced includes bounce_type (hard / soft) and reason.
  • complained includes the feedback report source.
  • opened includes user_agent and ip.
  • clicked includes url (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.

Every request includes two extra headers:

X-Runsite-Timestamp: 1714564938
X-Runsite-Signature: hex(HMAC-SHA256(secret, "{timestamp}.{body}"))

The verification recipe:

  1. Read X-Runsite-Timestamp and X-Runsite-Signature.
  2. Reject the request if the timestamp is more than ~5 minutes old (replay protection).
  3. Build the signed input: "<timestamp>.<raw request body as bytes>".
  4. Compute hex(HMAC-SHA256(secret, input)).
  5. Compare against X-Runsite-Signature using a constant-time comparison.
  6. 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.

import hmac
import hashlib
import os
import time
from 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 "", 204
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);
},
);
  • Reply with 2xx within ~10 seconds. The body is ignored.
  • Reply with 4xx to refuse and not be retried (use only for bad/unauthorized events you’ve already verified can’t be processed).
  • Reply with 5xx to 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.

A typical mapping:

EventApp reaction
deliveredMark 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.
complainedSuppress the recipient permanently. Possibly notify your support team.
opened / clickedEngagement metrics, A/B testing, lifecycle triggers.

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.