Gareth Wilson Gareth Wilson

Guide to Nitrosend Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Webhooks for email events, the API for account events
Limit10 webhooks per brand
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
SecretPer webhook, whsec_..., revealable later with ?reveal=true
BodyJSON
Event routingBody field type
Response window2xx within 10 seconds. Redirects fail
Retries6 retries over about 35 hours, then failed
Auto-disableAfter 3 days of failures, with a webhook.disabled event
Delivery logGET /v1/my/webhooks/{id}/deliveries, last 7 days

Events and payloads

Every event has the same outer shape:

FieldMeaning
typeThe dotted event type, for example email.bounced. This is what handlers switch on
timestampWhen the event happened (ISO 8601). Use it to order events
data.message_idThe id that POST /v1/my/messages returned
data.idempotency_keyThe Idempotency-Key you sent the message with; left out when you sent none
data.tagsThe tags you sent the message with
data.bounceOn email.bounced: type (the bounce kind, such as hard) and subtype. Not the event type
data.testtrue 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:

EventTriggered When
email.sentThe provider accepted the message
email.deliveredThe recipient's mail server accepted it
email.bouncedIt bounced, hard or soft
email.complainedThe recipient marked it as spam
email.openedThe first time it was opened. Security scanners and bots do not count
email.clickedThe first human click of each link in it
email.failedIt failed for good. A failure Nitrosend is still retrying is not reported
email.receivedA 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:

FieldMeaning
message_id, idempotency_key, tagsIdentify the send this answers; message_id is null and tags empty otherwise
from, to, subjectSender, inbox address and subject
text, htmlBodies, each capped at 64 KiB; truncated is true when either was cut
auto_submittedtrue for automatic replies such as out-of-office notices
attachmentsMetadata only (id, filename, size, content_type, scanned, scan_status, downloadable, download_requires_confirmation, optional availability_reason). Download only when downloadable is true
attachments_omittedOnly when the message carried more than 50 files; counts those past the limit
conversation_id, conversation_message_idUsed 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:

EventTriggered When
campaign.completedA campaign finished sending to its whole audience
campaign.failedA campaign send ended before reaching any recipient
campaign.stoppedNitrosend paused a campaign send because its bounce or spam-complaint rate crossed the stop threshold
flow.pausedNitrosend paused a flow because its bounce or spam-complaint rate crossed the pause threshold
sending.pausedNitrosend paused all sending on the account
sending.limit_approachingThe account has used most of its rolling 24-hour sending allowance
sending.limit_reachedThe account has used its whole allowance; sends wait for capacity
import.completedA contact import finished
import.failedA contact import failed
domain.verifiedA sending domain passed DNS verification and can send
domain.verification_lostA verified sending domain failed its DNS checks and is no longer verified
domain.dns_degradedA verified domain's DNS records could not be confirmed; it stays verified while Nitrosend rechecks
webhook.disabledNitrosend 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.disabled so 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 on webhook-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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.