API reference
Webhook payloads
The JSON that the platform delivers to your endpoint for each event type, and the headers that come with it.
You create endpoints in the console (Webhooks). See Webhooks for signing, verification and retries. Every delivery is an HTTPS POST with these headers:
| Header | Value |
|---|---|
content-type |
application/json |
user-agent |
f5send-webhooks/1 |
f5send-signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>"> |
f5send-delivery-id |
Unique id of this delivery (stable across retries). |
f5send-event-type |
The type below. |
Respond with any 2xx within 5 seconds.
email.* events
type is one of email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.rejected, email.opened, email.clicked, email.unsubscribed, email.failed.
{
"type": "email.bounced",
"created_at": "2026-08-17T14:03:13.902Z",
"data": {
"email_id": "cmf9x1v0000018lfr6zot86p",
"message_id": "<010001a07c2c5e51-721369c3-81aa-452b-b593-93cec56414c0-000000@email.amazonses.com>",
"from": "Acme <hello@example.com>",
"to": ["jane@example.org"],
"subject": "Welcome to Acme",
"recipient": "jane@example.org",
"kind": "invite",
"tags": [{ "name": "week", "value": "2026-33" }],
"metadata": { "ticket_id": "T-42" },
"bounce_type": "hard",
"detail": "General"
}
}
| Field | Notes |
|---|---|
email_id |
The message id from POST /emails. |
message_id |
The Message-ID header that recipients see. Replies name it in In-Reply-To. null when the platform does not know it, see Message-ID and replies. Every event after the provider accepts the message has it. |
from, to, subject |
As sent. to is the to list of the message, not cc/bcc. |
recipient |
The one address this event is about. |
kind |
The message kind, or null. |
tags |
The tags array you sent, or — for template sends — { "template": { "id", "slug", "version" }, "tags": [...] }. null if none. |
metadata |
The metadata map you sent, or null. |
bounce_type |
email.bounced only: hard, soft or undetermined. |
detail |
Provider sub-type (General, MailboxFull, …), delay type for email.delivery_delayed, or the failure text for email.failed. |
url |
email.clicked only: the destination link. |
The payload omits fields with no value.
email.received
The platform sends this event when a reply arrives on reply.<domain> (see Inbound).
{
"type": "email.received",
"created_at": "2026-08-17T15:10:02.000Z",
"data": {
"inbound_id": "cmi2…",
"email_id": "cmf9x1v0000018lfr6zot86p",
"in_reply_to_message_id": "<010001a07c2c5e51-721369c3-81aa-452b-b593-93cec56414c0-000000@email.amazonses.com>",
"metadata": { "ticket_id": "T-42" },
"from": "jane@example.org",
"to": ["reply@reply.example.com"],
"subject": "Re: Welcome to Acme"
}
}
email_id is present only when the platform could thread the reply to a message you sent. To thread a reply, the platform needs its In-Reply-To or References header to name one of your messages. Two forms match: the message_id that SES assigned, and <email_id@your domain>. With a thread match, in_reply_to_message_id and metadata are those of your message.
Verify a webhook
The HMAC covers the exact body bytes. A parsed body that you serialise again has different bytes, so verification fails.
Use verifyWebhookSignature from sdk/f5send.ts. Pass the raw body. Do not parse JSON first. The default tolerance is 300 seconds.
Next.js App Router
import { verifyWebhookSignature } from "./f5send";
export async function POST(request: Request) {
const rawBody = await request.text();
const result = verifyWebhookSignature({
rawBody,
headers: request.headers,
secret: process.env.F5SEND_WEBHOOK_SECRET!,
});
if (!result.ok) return new Response("unauthorized", { status: 401 });
return new Response("ok");
}
Hono
import { Hono } from "hono";
import { verifyWebhookSignature } from "./f5send";
const app = new Hono();
app.post("/webhooks/f5send", async (c) => {
const rawBody = await c.req.raw.text();
const result = verifyWebhookSignature({
rawBody,
headers: c.req.raw.headers,
secret: process.env.F5SEND_WEBHOOK_SECRET!,
});
if (!result.ok) return c.text("unauthorized", 401);
return c.text("ok");
});
Express
import express from "express";
import { verifyWebhookSignature } from "./f5send";
const app = express();
app.post("/webhooks/f5send", express.raw({ type: "*/*" }), (req, res) => {
const result = verifyWebhookSignature({
rawBody: req.body as Uint8Array,
headers: req.headers,
secret: process.env.F5SEND_WEBHOOK_SECRET!,
});
if (!result.ok) return res.status(401).send("unauthorized");
res.send("ok");
});
request.text(), c.req.raw.text(), and express.raw({ type: "*/*" }) keep the original bytes.