Webhooks
Have each event sent to your app as it happens: deliveries, bounces, spam reports, opens and clicks.
Set up
- Turn on the events you want under Admin → API → Events. Nothing is recorded until you do.
- Add a webhook under Admin → API → Webhook: an
https://address in your app, and the events to send to it. - Copy its secret (
whsec_…) into your app's settings, such asTORVATH_WEBHOOK_SECRET. - Press Send test event to check your code. Test events carry
"test": true.
One organisation has one webhook. Rather fetch events yourself? Use the events queue instead.
The request
Each event is a POST with the event as JSON and three headers, following Standard Webhooks.
POST /webhooks/torvath HTTP/1.1
webhook-id: msg_Zr4pQ8sLx2Nk7vBw
webhook-timestamp: 1794470405
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
content-type: application/json
{
"id": "msg_Zr4pQ8sLx2Nk7vBw",
"type": "email.bounced",
"createdAt": 1794470405000,
"emailId": "em_7Kq2xY9pLm3nB8vR",
"recipient": "old@example.com",
"tags": ["invoice"],
"metadata": { "invoice": "1042" },
"category": "address_invalid",
"reason": "no_such_user",
"title": "Address not found",
"smtpCode": 550,
"status": "5.1.1",
"diagnostic": "550 5.1.1 User unknown"
}Check the signature
Check every request before trusting it, using the raw body before it is parsed. webhook-signature is v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the bytes of the secret after whsec_.
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@torvath/mail";
const app = express();
// The raw body, exactly as it arrived: the signature is over these bytes.
app.post("/webhooks/torvath", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await verifyWebhook(req.body, req.headers, process.env.TORVATH_WEBHOOK_SECRET);
await handle(event); // safe to run twice: event.id is the same on every try
res.sendStatus(200);
} catch (err) {
res.sendStatus(err instanceof WebhookVerificationError ? 400 : 500);
}
});Standard Webhooks' libraries for other languages check these requests too.
Answering and retries
Answer with any 2xx within 10 seconds. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then every 12 hours, for 3 days. A webhook that fails every request for 3 days is turned off and your admins are emailed.
Make your handler safe to run twice: id (the same as webhook-id) stays the same on every try, so store the ids you have handled. Do slow work after answering.
Event types
| type | When | Extra fields |
|---|---|---|
email.sent | Handed to the receiving side. | — |
email.delivered | Accepted by the recipient's mail server. | — |
email.bounced | Refused by the recipient's server. | category, reason, title, smtpCode, status, diagnostic |
email.complained | The recipient reported it as spam. | feedbackType |
email.failed | Couldn't be sent after every retry. | category, reason, title, diagnostic |
email.opened | Opened, with open tracking on. | machine |
email.clicked | A link was clicked, with click tracking on. | url, machine |
Opens and clicks come only with tracking on (Business plan, single-recipient emails). Apple Mail loads every image whether the mail is read or not, and company mail scanners follow links, so these events carry "machine": true when a machine was likely behind them.
Event fields
idstringrequiredThe same as the webhook-id header, and the same on every try: use it to handle an event once
typestringrequiredOne of
email.sentemail.deliveredemail.bouncedemail.complainedemail.failedemail.openedemail.clickedcreatedAtintegerrequiredemailIdstringrequiredrecipientstringrequiredmetadataobjectrequiredstring valuestestbooleanoptionalSent with Send test event; not about a real email
categorystringoptionalemail.bounced, email.failed
reasonstringoptionalemail.bounced, email.failed
titlestringoptionalsmtpCodeinteger or nulloptionalstatusstring or nulloptionaldiagnosticstring or nulloptionalfeedbackTypestring or nulloptionalemail.complained
machinebooleanoptionalemail.opened, email.clicked: likely a machine, not a person
urlstringoptionalemail.clicked