Guide to Nitrosend Webhooks: Features and Best Practices
Nitrosend is an email platform for transactional and marketing mail, with an agent inbox that receives replies. Its webhooks cover three things: what happened to each transactional email you sent, what came back into your brand's inboxes, and changes to the account that need attention, such as sending being paused or a domain losing verification.
What are Nitrosend webhooks?
A Nitrosend webhook belongs to a brand, and a brand can have up to ten. Each one has a URL, a list of events and its own whsec_ secret. Requests are signed in the Standard Webhooks format, so any Standard Webhooks library can verify them, although webhook-id values are Nitrosend's own evt_ IDs rather than Svix msg_ IDs.
Email events only cover transactional mail: single messages your brand sends, not campaigns or flows. SMS and test sends don't produce email events, and because opens and clicks come from Nitrosend's own tracking, a brand that turns that tracking off stops receiving email.opened and email.clicked.
Nitrosend webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Webhooks for email events, the API for account events |
| Limit | 10 webhooks per brand |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Secret | Per webhook, whsec_..., revealable later with ?reveal=true |
| Body | JSON |
| Event routing | Body field type |
| Response window | 2xx within 10 seconds. Redirects fail |
| Retries | 6 retries over about 35 hours, then failed |
| Auto-disable | After 3 days of failures, with a webhook.disabled event |
| Delivery log | GET /v1/my/webhooks/{id}/deliveries, last 7 days |
Events and payloads
Every event has the same outer shape:
| Field | Meaning |
|---|---|
type | The dotted event type, for example email.bounced. This is what handlers switch on |
timestamp | When the event happened (ISO 8601). Use it to order events |
data.message_id | The id that POST /v1/my/messages returned |
data.idempotency_key | The Idempotency-Key you sent the message with; left out when you sent none |
data.tags | The tags you sent the message with |
data.bounce | On email.bounced: type (the bounce kind, such as hard) and subtype. Not the event type |
data.test | true on test events, whose ids are null |
The webhook-id header (evt_...) stays the same across retries, so it's the key to deduplicate on.
Email events report on transactional sends:
| Event | Triggered When |
|---|---|
email.sent | The provider accepted the message |
email.delivered | The recipient's mail server accepted it |
email.bounced | It bounced, hard or soft |
email.complained | The recipient marked it as spam |
email.opened | The first time it was opened. Security scanners and bots do not count |
email.clicked | The first human click of each link in it |
email.failed | It failed for good. A failure Nitrosend is still retrying is not reported |
email.received | A message reached one of the brand's inboxes and was not quarantined: a reply, an automatic reply, or new mail to the inbox address |
email.received is the richest event. It carries the inbound message, linked to the send it answers when there is one:
| Field | Meaning |
|---|---|
message_id, idempotency_key, tags | Identify the send this answers; message_id is null and tags empty otherwise |
from, to, subject | Sender, inbox address and subject |
text, html | Bodies, each capped at 64 KiB; truncated is true when either was cut |
auto_submitted | true for automatic replies such as out-of-office notices |
attachments | Metadata only (id, filename, size, content_type, scanned, scan_status, downloadable, download_, optional availability_reason). Download only when downloadable is true |
attachments_omitted | Only when the message carried more than 50 files; counts those past the limit |
conversation_id, conversation_message_id | Used to download attachments from the conversation API |
Mail the inbox quarantines, such as spam, viruses and blocked senders, isn't sent, and each message is reported once.
Account events need attention from whoever runs your email programme:
| Event | Triggered When |
|---|---|
campaign.completed | A campaign finished sending to its whole audience |
campaign.failed | A campaign send ended before reaching any recipient |
campaign.stopped | Nitrosend paused a campaign send because its bounce or spam-complaint rate crossed the stop threshold |
flow.paused | Nitrosend paused a flow because its bounce or spam-complaint rate crossed the pause threshold |
sending.paused | Nitrosend paused all sending on the account |
sending.limit_ | The account has used most of its rolling 24-hour sending allowance |
sending.limit_reached | The account has used its whole allowance; sends wait for capacity |
import.completed | A contact import finished |
import.failed | A contact import failed |
domain.verified | A sending domain passed DNS verification and can send |
domain.verification_lost | A verified sending domain failed its DNS checks and is no longer verified |
domain.dns_degraded | A verified domain's DNS records could not be confirmed; it stays verified while Nitrosend rechecks |
webhook.disabled | Nitrosend turned off one of the brand's webhooks after three days of failed deliveries |
The three sending.* events reach the webhooks of every brand in the account; the others go only to the brand they concern.
This is the email.bounced sample from the webhook samples library. data.bounce.type is the kind of bounce, not the event type:
{
"type": "email.bounced",
"timestamp": "2026-09-30T10:00:00.000Z",
"data": {
"message_id": 48213,
"to": "ada@example.com",
"subject": "Reset your password",
"idempotency_key": "reset-8841",
"tags": {
"user_id": "42"
},
"bounce": {
"type": "hard",
"subtype": "NoEmail"
}
}
}
email.bounced and email.received are the only events with complete published bodies. The rest are described by Nitrosend's WebhookEventPayload schema, and Nitrosend's Send test event accepts any type, so you can see one before writing a parser.
Setting up Nitrosend webhooks
In the dashboard, open Settings > Webhooks (app.nitrosend.com/my/settings/webhooks), choose New Webhook, enter the URL and pick events. The page lists email events only, and only people who can manage API keys see it.
To subscribe to account events, create the webhook through the API instead:
curl -X POST https://api.nitrosend.com/v1/my/webhooks \
-H "Authorization: Bearer $NITROSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/nitrosend",
"events": ["email.delivered", "email.bounced", "email.complained", "email.failed"]
}'
The URL must use HTTPS, resolve to a public address and carry no credentials. Nitrosend doesn't send a challenge request and doesn't follow redirects. Manage webhooks with GET /v1/my/webhooks, PATCH /v1/my/webhooks/{id} (URL, events, on or off) and DELETE /v1/my/webhooks/{id}.
The create response includes the full secret. Later reads mask it, but you can reveal it from the dashboard or with GET /v1/my/webhooks/{id}?reveal=true. Store it in NITROSEND_WEBHOOK_SECRET, prefix included.
To route Nitrosend through Hookdeck, create a source with the Nitrosend type, paste the secret, and use the source URL as the webhook's url. Run the Hookdeck CLI to bring it to localhost:
npx hookdeck-cli listen 3000 nitrosend --path /webhooks/nitrosend
Securing Nitrosend webhooks
Nitrosend documents the Standard Webhooks steps: base64-decode the part of the secret after whsec_ to get the key, sign {webhook-id}.{webhook-timestamp}.{raw body} with HMAC-SHA256, base64-encode, and compare with each v1, entry in webhook-signature:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Nitrosend says to reject a request whose webhook-timestamp is more than five minutes from now. Hookdeck applies no such window, and its retries and replays carry the original timestamp, so a receiver behind Hookdeck should leave that check out, as the function above does.
email.received can carry up to 64 KiB of message text, so the receiver stores the event and lets a worker handle replies and attachments:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// mailEvents.record(eventId, event) stores the event and resolves once it is durable.
function createNitrosendReceiver(mailEvents) {
const app = express();
app.post('/webhooks/nitrosend', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.NITROSEND_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
if (event.data && event.data.test) return res.sendStatus(200); // test events have null IDs
try {
await mailEvents.record(req.headers['webhook-id'], event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
A Hookdeck Nitrosend source responds 200 to every request. Ones with a bad signature are kept as failed verification in Hookdeck and not delivered.
Make Nitrosend webhooks production-ready. Hookdeck Event Gateway verifies every email and account event and can send replies to your support tooling and bounces to your list hygiene job.
Nitrosend webhook limitations and pain points
Three days of failures turn the webhook off
The Problem: After a long outage, a webhook is disabled and events from that period are missing.
Why It Happens: Nitrosend retries a failed event six times over about 35 hours. After three days of continuous failure it turns the webhook off and sends webhook.disabled, and events during the outage aren't sent.
Workarounds:
- Subscribe a second, separate webhook to
webhook.disabledso you hear about it. - Use the deliveries endpoint to see what failed, then turn the webhook back on with
PATCH.
How Hookdeck Can Help: Hookdeck accepts each event as Nitrosend sends it, so the webhook never fails, and retries your service once it recovers.
Account events are API-only
The Problem: Your team set up webhooks in the dashboard and never received sending.paused or domain.verification_lost.
Why It Happens: The dashboard lists email events only. Account events have to be added through the API.
Workarounds:
- Add account events with
PATCH /v1/my/webhooks/{id}, or create a separate webhook for them.
How Hookdeck Can Help: Hookdeck filters on type can split email and account events from one webhook into different destinations.
Opens and clicks depend on tracking
The Problem: Engagement events stop for one brand.
Why It Happens: email.opened and email.clicked come from Nitrosend's tracking. If a brand turns it off, those events stop.
Workarounds:
- Check the brand's tracking settings before treating missing opens as a delivery problem.
How Hookdeck Can Help: Hookdeck's event history by type makes it easy to see when a kind of event stopped arriving.
Events can arrive out of order
The Problem: email.delivered is processed after email.bounced for the same message.
Why It Happens: A retried event can arrive after later ones.
Workarounds:
- Order by the body's
timestamp, and deduplicate onwebhook-id.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeat webhook-id values before they reach you.
Best practices
Suppress on hard bounces and complaints
Add the address to your suppression list on email.bounced with bounce.type of hard, and on email.complained.
Match replies to sends
email.received includes message_id, idempotency_key and tags from the send it answers. Use them to attach the reply to the right record, and check auto_submitted before treating it as a human reply.
Download attachments only when allowed
Attachments come as metadata. Download through the conversation API only when downloadable is true.
Record webhook-id with every effect
Retries repeat events, so store the ID with each change it causes. Our guide to idempotent webhook handling shows how.
Conclusion
Nitrosend reports each transactional email's delivery, engagement and replies, along with account problems that stop sending. A webhook that fails for three days switches itself off, so monitor it.
Hookdeck Event Gateway keeps Nitrosend webhooks healthy through outages and routes each event type where it's needed. Open a Hookdeck account.