Send · Webhooks

Every event, signed and retried.

Delivery, bounce, open, click, complaint and inbound events are posted to your endpoint, signed in the open Standard Webhooks format, and retried on a schedule until you answer.

What your endpoint receivesHTTP
POST /hooks/email HTTP/1.1
Host: nimbu.example
Content-Type: application/json
User-Agent: Refiremail-Webhooks/1.0
webhook-id: msg_01a0c76ea0a4794ea31a61dbe22e4415
webhook-timestamp: 1790052311
webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>

{
  "type": "email.bounced",
  "created_at": "2026-09-22T04:45:11.204Z",
  "data": {
    "email_id": "01a0c76e-8046-71e2-a78a-6a63ec24ede6",
    "created_at": "2026-09-22T04:45:02.918Z",
    "from": "Nimbu <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your login code",
    "bounce": {
      "type": "Permanent",
      "subType": "NoEmail",
      "message": "The recipient's mailbox does not exist.",
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"]
    }
  }
}

01, Events

Nineteen events, one shape.

Subscribe each endpoint to the events it handles. Every body is { type, created_at, data }, and the event names follow the resource.action pattern.

  • /webhooks, Live
EventSent when
Email
email.scheduledAn email was accepted with a scheduled_at time.
email.sentWe handed the message on for delivery to the recipient’s mail server.
email.deliveredThe recipient’s mail server accepted it.
email.delivery_delayedA temporary failure. We retry on our own schedule.
email.bouncedThe recipient’s server refused it: permanent, transient or undetermined, with the reason.
email.complainedThe recipient reported it as spam.
email.openedThe open pixel loaded. machine_open says when a scanner or privacy proxy did it.
email.clickedA tracked link was followed, with the link and user agent.
email.suppressedA recipient was skipped because of an earlier bounce or complaint.
email.failedWe stopped trying: retries ran out, the failure was permanent, or the domain lost verification.
email.receivedMail arrived at one of your receiving addresses.
Contact
contact.createdA contact was added by the API, the dashboard or a form. Not sent for CSV imports.
contact.updatedA field, property, topic or subscription changed, including an unsubscribe.
contact.deletedA contact was removed. The payload is the last known record.
Domain
domain.createdA sending or receiving domain was added.
domain.updatedIts verification status or records changed, including a lost DKIM record.
domain.deletedThe domain was removed.
Suppression
suppression.addedAn address joined your suppression list, from a bounce, a complaint or by hand.
suppression.removedAn address left the list, by hand or when a temporary suppression expired.

02, Verify

Verify with the standard library.

Signatures use the open Standard Webhooks format. Check them with an HMAC from your language’s standard library: the raw body, three headers and your endpoint’s secret.

  • The signature is HMAC-SHA256 over id.timestamp.body, keyed with your endpoint’s whsec_ secret.
  • The verifier rejects a request more than 5 minutes old, so a captured request can’t be replayed later.
  • The headers are webhook-id, webhook-timestamp and webhook-signature, so any Standard Webhooks library can verify them too.
  • Verify the raw bytes before parsing the JSON. A re-serialised body won’t match.
app/hooks/email/route.tsTypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';

// The endpoint's signing secret is "whsec_" + base64. The HMAC key is the decoded part.
const key = Buffer.from(process.env.REFIREMAIL_WEBHOOK_SECRET!.replace('whsec_', ''), 'base64');

export function verifySignature(headers: Headers, body: string): boolean {
  const id = headers.get('webhook-id') ?? '';
  const ts = headers.get('webhook-timestamp') ?? '';
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest();
  // "v1,<sig>", space-separated: two of them for 24 hours after a secret rotation.
  return (headers.get('webhook-signature') ?? '').split(' ').some((entry) => {
    const got = Buffer.from(entry.split(',')[1] ?? '', 'base64');
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, before any JSON parsing
  if (!verifySignature(req.headers, body)) return new Response('Invalid signature', { status: 400 });

  const event = JSON.parse(body);
  if (event.type === 'email.bounced') {
    // We have already suppressed the address. Update your own records too.
    await flagAddress(event.data.to);
  }
  return new Response(null, { status: 204 });
}
Shell
pip install flask
app.pyPython
import base64, hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
# The endpoint's signing secret is "whsec_" + base64. The HMAC key is the decoded part.
KEY = base64.b64decode(os.environ["REFIREMAIL_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verified(headers, body: bytes) -> bool:
    msg_id, ts = headers.get("webhook-id", ""), headers.get("webhook-timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(KEY, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    expected = base64.b64encode(mac).decode()
    # "v1,<sig>", space-separated: two of them for 24 hours after a secret rotation.
    sigs = [s.partition(",")[2] for s in headers.get("webhook-signature", "").split()]
    return any(hmac.compare_digest(s, expected) for s in sigs)


@app.post("/hooks/email")
def email_events():
    body = request.get_data()  # the raw bytes, before any JSON parsing
    if not verified(request.headers, body):
        return "Invalid signature", 400

    event = json.loads(body)
    if event["type"] == "email.bounced":
        # We have already suppressed the address. Update your own records too.
        flag_address(event["data"]["to"])
    return "", 204

03, Retries

Retries you can plan around.

Any 2xx within 15 seconds is a success. Anything else, a timeout or a refused connection, and we try again on a fixed schedule for about 27 and a half hours.

  • Delivery is at least once and not ordered. Dedupe on webhook-id, which stays the same on every retry and replay, and sort by created_at.
  • Each attempt is signed again with a fresh timestamp, so a late retry never trips the 5-minute window.
  • Each wait varies by up to 10% either way, so a busy endpoint isn’t hit by every retry at the same moment.
  1. Attempt 1

    at once

    at 0 s

  2. Attempt 2

    +5 s

    at 5 s

  3. Attempt 3

    +5 min

    at 5 min

  4. Attempt 4

    +30 min

    at 35 min

  5. Attempt 5

    +2 h

    at 2 h 35 min

  6. Attempt 6

    +5 h

    at 7 h 35 min

  7. Attempt 7

    +10 h

    at 17 h 35 min

  8. Attempt 8

    +10 h

    at 27 h 35 min

After the eighth attempt the event is marked failed. It stays in the event log, and you can replay it whenever you like.

04, Rotation

Rotate a secret without downtime.

Rotate from the dashboard or with POST /webhooks/{id}/signing-secret/rotate. For the next 24 hours every delivery carries two signatures, one for each secret.

  • Verifiers accept a request when any one signature matches, so the old secret keeps working until you deploy the new one.
  • Each endpoint has its own secret. Rotating one never touches another.
Headers during the 24-hour overlapHTTP
webhook-id: msg_01a0c76ea0a4794ea31a61dbe22e4415
webhook-timestamp: 1790052311
webhook-signature: v1,<new secret's signature> v1,<old secret's signature>

05, Replay

Replay anything, and see every attempt.

Re-send a recent event from the dashboard or the API. Every event keeps its attempts: when each was made, the status code, and the start of your response.

  • A replay is one extra delivery, sent at once. It keeps the same webhook-id, so your dedupe still works, and it doesn’t restart the retry schedule.
  • GET /webhooks/{id}/events lists recent events as pending, attempting, success or failed.
  • The first 4 KB of each response body is kept, usually enough to read your own error message.
Webhooks · nimbu.exampleSample data
GET …/events/{event_id}/attemptsJSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "atmpt_01a0c7737d2874cb8d5c90a9587403e4",
      "http_status_code": 200,
      "response": "{\"ok\":true}",
      "sent_at": "2026-09-22T04:50:29.800Z"
    },
    {
      "id": "atmpt_01a0c76eef2476d7b6881ed162ae2eb1",
      "http_status_code": 503,
      "response": "Service Unavailable",
      "sent_at": "2026-09-22T04:45:31.300Z"
    },
    {
      "id": "atmpt_01a0c76ea1047101a2b64ce4228c38fb",
      "http_status_code": null,
      "response": null,
      "sent_at": "2026-09-22T04:45:11.300Z"
    }
  ]
}
An endpoint’s recent events, the three attempts behind one of them, and Replay. Switch to API for the attempts list.

06, Details

What an endpoint needs to know.

The rules we follow when we call you, and what happens when your endpoint misbehaves.

  • Public HTTPS only

    Endpoints must be public HTTPS URLs. Private and internal addresses are refused, and redirects are not followed.

  • 15 seconds to answer

    Reply with any 2xx within 15 seconds, then do the slow work in the background.

  • Disabled after 5 days of failure

    An endpoint that fails every attempt for 5 days is disabled.

  • One secret per endpoint

    Each endpoint gets its own whsec_ secret, shown in the dashboard and returned by the API.

  • Only the events you pick

    Subscribe each endpoint to the event types it handles. Unknown names are refused when you save.

  • One id everywhere

    The webhook-id header is also the event’s id in the API, so a log line leads straight to its attempts.

Wire up your first endpoint.

Request early access. Once your account is on, add an endpoint and verify the first delivery in a few lines.