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.
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
| Event | Sent when |
|---|---|
| email.scheduled | An email was accepted with a scheduled_at time. |
| email.sent | We handed the message on for delivery to the recipient’s mail server. |
| email.delivered | The recipient’s mail server accepted it. |
| email.delivery_delayed | A temporary failure. We retry on our own schedule. |
| email.bounced | The recipient’s server refused it: permanent, transient or undetermined, with the reason. |
| email.complained | The recipient reported it as spam. |
| email.opened | The open pixel loaded. machine_open says when a scanner or privacy proxy did it. |
| email.clicked | A tracked link was followed, with the link and user agent. |
| email.suppressed | A recipient was skipped because of an earlier bounce or complaint. |
| email.failed | We stopped trying: retries ran out, the failure was permanent, or the domain lost verification. |
| email.received | Mail arrived at one of your receiving addresses. |
| Contact | |
| contact.created | A contact was added by the API, the dashboard or a form. Not sent for CSV imports. |
| contact.updated | A field, property, topic or subscription changed, including an unsubscribe. |
| contact.deleted | A contact was removed. The payload is the last known record. |
| Domain | |
| domain.created | A sending or receiving domain was added. |
| domain.updated | Its verification status or records changed, including a lost DKIM record. |
| domain.deleted | The domain was removed. |
| Suppression | |
| suppression.added | An address joined your suppression list, from a bounce, a complaint or by hand. |
| suppression.removed | An 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’swhsec_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-timestampandwebhook-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.
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 bycreated_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.
Attempt 1
at once
at 0 s
Attempt 2
+5 s
at 5 s
Attempt 3
+5 min
at 5 min
Attempt 4
+30 min
at 35 min
Attempt 5
+2 h
at 2 h 35 min
Attempt 6
+5 h
at 7 h 35 min
Attempt 7
+10 h
at 17 h 35 min
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.
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}/eventslists 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.
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.