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:

bash
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:

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

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

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 "", 204

Go

go
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:

bash
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.