# Guide to Nitrosend Webhooks: Features and Best Practices

[Nitrosend](https://nitrosend.com) 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_requires_confirmation`, 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_approaching` | 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/nitrosend/latest/email.bounced.json). `data.bounce.type` is the kind of bounce, not the event type:

```json
{
  "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](https://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:

```bash
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](/docs/cli) to bring it to localhost:

```bash
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`:

```javascript
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:

```javascript
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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) keeps Nitrosend webhooks healthy through outages and routes each event type where it's needed. [Open a Hookdeck account](https://dashboard.hookdeck.com/signup).