Webhooks

Have each event sent to your app as it happens: deliveries, bounces, spam reports, opens and clicks.

Set up

  1. Turn on the events you want under Admin → API → Events. Nothing is recorded until you do.
  2. Add a webhook under Admin → API → Webhook: an https:// address in your app, and the events to send to it.
  3. Copy its secret (whsec_…) into your app's settings, such as TORVATH_WEBHOOK_SECRET.
  4. 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.

An email.bounced event
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.

After New secret, requests carry both signatures, space-separated, for a day. Update your app's secret within that day and no event is missed.

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

typeWhenExtra fields
email.sentHanded to the receiving side.—
email.deliveredAccepted by the recipient's mail server.—
email.bouncedRefused by the recipient's server.category, reason, title, smtpCode, status, diagnostic
email.complainedThe recipient reported it as spam.feedbackType
email.failedCouldn't be sent after every retry.category, reason, title, diagnostic
email.openedOpened, with open tracking on.machine
email.clickedA 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

  • idstringrequired

    The same as the webhook-id header, and the same on every try: use it to handle an event once

  • typestringrequired

    One ofemail.sentemail.deliveredemail.bouncedemail.complainedemail.failedemail.openedemail.clicked

  • createdAtintegerrequired
  • emailIdstringrequired
  • recipientstringrequired
  • tagsarray of stringsrequired
  • metadataobjectrequiredstring values
  • testbooleanoptional

    Sent with Send test event; not about a real email

  • categorystringoptional

    email.bounced, email.failed

  • reasonstringoptional

    email.bounced, email.failed

  • titlestringoptional
  • smtpCodeinteger or nulloptional
  • statusstring or nulloptional
  • diagnosticstring or nulloptional
  • feedbackTypestring or nulloptional

    email.complained

  • machinebooleanoptional

    email.opened, email.clicked: likely a machine, not a person

  • urlstringoptional

    email.clicked

© 2026 Torvath. Questions? support@torvathmail.com