Email sandbox
On this page
Webhooks
A webhook is an HTTPS endpoint of yours that Testlie calls when mail arrives: to kick off a test step, post to chat, or feed your own tooling. Webhooks are included from the Team plan.
Creating a webhook
Admins add webhooks on the Webhooks page or an inbox's Forwarding & webhooks tab, or with the API:
curl -X POST https://testlie.com/api/webhooks \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://ci.example.com/hooks/mail", "inboxId": "<inbox id>" }'| Field | Notes |
|---|---|
url |
http or https, no credentials in the URL. Targets that resolve to private, loopback or link-local addresses are refused. |
inboxId |
Only this inbox. Leave it out for every inbox in the team. |
events |
Event names to send; empty means all. Today there is one: message.received. |
enabled |
Disabled webhooks keep their settings but receive nothing. |
The response contains the signing secret (whsec_...). Admins can see it again later; members cannot.
Payload
Each delivery is a JSON POST:
{
"event": "message.received",
"inbox": { "id": "cm1x9...", "name": "Checkout · staging" },
"message": {
"id": "k2m9v0q3...",
"subject": "Welcome aboard",
"from": { "address": "app@example.com", "name": "Example App" },
"to": [{ "address": "new-user@example.com", "name": "" }],
"envelopeTo": ["new-user@example.com"],
"receivedAt": "2026-10-14T09:30:12.345Z",
"url": "https://testlie.com/inboxes/cm1x9...?message=k2m9v0q3..."
}
}Fetch GET /api/messages/<message.id> with an API key for the body, headers and attachments. Send test on a webhook posts the same shape with "event": "webhook.test" and "test": true.
Headers on every delivery:
| Header | Value |
|---|---|
X-Webhook-Event |
The event name |
X-Webhook-Timestamp |
Unix time in seconds when this attempt was signed |
X-Webhook-Signature |
sha256= and the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the webhook's secret |
User-Agent |
testlie-webhooks/1 |
Verifying signatures
Compute the HMAC over the timestamp, a dot and the raw request body (before any JSON parsing), compare in constant time, and reject old timestamps to stop replays.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/mail", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-Webhook-Timestamp");
const sig = req.get("X-Webhook-Signature") ?? "";
const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET).update(`${ts}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return res.status(401).end();
const event = JSON.parse(req.body);
console.log(event.event, event.message.subject);
res.status(204).end();
});Python
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/mail")
def mail_hook():
ts = request.headers.get("X-Webhook-Timestamp", "")
sig = request.headers.get("X-Webhook-Signature", "")
body = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(os.environ["WEBHOOK_SECRET"].encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
return "", 204Go
func mailHook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
ts := r.Header.Get("X-Webhook-Timestamp")
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 ||
!hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(expected)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
}Retries
Answer with any 2xx status within 10 seconds. A delivery is retried when the request fails (connection error or timeout) or your endpoint answers 5xx, 408 or 429, after about 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours and 4 hours, so roughly eight hours in all. Any other 4xx is treated as final and not retried. Each retry is signed afresh, with a new timestamp.
Deliveries can arrive more than once (for example after a timeout on a request your endpoint did process) and out of order; use message.id to deduplicate.
Delivery log
Each webhook keeps a log of its deliveries for 30 days: event, status (pending, retrying, succeeded, failed), HTTP status, error, attempts and duration. The webhook itself shows its last status and error; the full log is in the API:
curl -H "Authorization: Bearer $API_KEY" https://testlie.com/api/webhooks/<webhook id>/deliveries?limit=50
# Redeliver one (same payload, new signature):
curl -X POST -H "Authorization: Bearer $API_KEY" https://testlie.com/api/webhooks/<webhook id>/deliveries/<delivery id>Receiving webhooks on your laptop
Webhook targets must be reachable from the internet and must not resolve to a private address. Use a tunnel such as cloudflared tunnel --url http://localhost:3000 or ngrok and register the public URL.