# Nitrosend Webhooks

[Nitrosend](https://nitrosend.com) is a transactional and marketing email platform with an agent inbox. It POSTs a signed JSON event to each endpoint you register (up to 10 per brand) when a transactional email is sent, delivered, bounced, complained about, opened, clicked or fails, when a reply reaches one of your inboxes, and when account state changes (campaigns, sending limits, imports, domains). Signatures follow Standard Webhooks, so each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers.

## When to Use This Skill

* How do I receive Nitrosend webhooks?
* How do I verify a Nitrosend webhook signature (`webhook-signature`)?
* Where do I find my Nitrosend webhook secret (`whsec_...`)?
* How do I handle `email.delivered`, `email.bounced`, `email.complained` or `email.received` events?
* How do I react to `sending.limit_reached`, `domain.verification_lost` or `webhook.disabled`?
* Why is my Nitrosend webhook verification failing, or failing only on retries and replays?

## Verification (core)

| Header | Value |
| --- | --- |
| `webhook-id` | `evt_...`; retries reuse it. Your idempotency key |
| `webhook-timestamp` | Unix seconds |
| `webhook-signature` | One or more space-separated `v1,<base64>` entries |

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256, base64 output. The key is the base64-**decoded** part of the `whsec_...` secret after the prefix. Compare against every `v1,` entry; ignore other versions. If `webhook-id` is absent, read `svix-id`, `svix-timestamp` and `svix-signature` instead, as Hookdeck does. The examples use the `standardwebhooks` library (npm and PyPI):

```javascript
const { Webhook } = require('standardwebhooks');

// rawBody: exact request body (Buffer or string), NOT re-serialised JSON
function verifyNitrosend(rawBody, headers, secret = process.env.NITROSEND_WEBHOOK_SECRET) {
  const p = headers['webhook-id'] ? 'webhook' : 'svix'; // fall back to the svix-* names
  new Webhook(secret).verify(rawBody, {
    'webhook-id': headers[`${p}-id`],
    'webhook-timestamp': headers[`${p}-timestamp`],
    'webhook-signature': headers[`${p}-signature`],
  }, { jsonParse: false }); // throws on a bad signature or stale timestamp → respond 401
}

```

The library always enforces a fixed **5-minute timestamp window**, which is also what Nitrosend tells you to apply. Hookdeck retries and replays resend the original `webhook-timestamp`, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries to pass, use the manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/nitrosend-webhooks/references/verification.md), which can drop or widen the window. In Express, read the body with `express.raw({ type: '*/*' })` so any content type verifies, and parse JSON only after the check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/nitrosend-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/nitrosend-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/nitrosend-webhooks/examples/fastapi/).

## Event Type and Payload

Every body is `{ type, timestamp, data }`. The event name is the top-level `type` (dotted, such as `email.bounced`), `timestamp` is when the event happened, and the event fields sit under `data`:

```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" }
  }
}

```

`data.bounce.type` (`hard`) is the bounce kind, not the event type. Test events sent from the dashboard or `POST /v1/my/webhooks/{id}/test` carry `"test": true` in `data` with null ids.

## Common Event Types

| Event | Sent when |
| --- | --- |
| `email.sent` | The provider accepted the message |
| `email.delivered` | The recipient's mail server accepted it |
| `email.bounced` | It bounced, hard or soft (`data.bounce`) |
| `email.complained` | The recipient marked it as spam |
| `email.opened` | First open (scanners and bots don't count) |
| `email.clicked` | First human click of each link |
| `email.failed` | It failed for good |
| `email.received` | A reply, automatic reply or new mail reached one of the brand's inboxes |
| `campaign.completed` / `campaign.failed` / `campaign.stopped` | A campaign finished, ended before reaching anyone, or was paused for a high bounce or complaint rate |
| `flow.paused` | A flow was paused for a high bounce or complaint rate |
| `sending.paused` | All sending on the account was paused |
| `sending.limit_approaching` / `sending.limit_reached` | Most of, or all of, the rolling 24-hour sending allowance is used |
| `import.completed` / `import.failed` | A contact import finished or failed |
| `domain.verified` / `domain.verification_lost` / `domain.dns_degraded` | A sending domain passed verification, lost it, or its DNS could not be confirmed |
| `webhook.disabled` | Nitrosend turned off a webhook after 3 days of failures |

These 21 are the documented events; the list may not be exhaustive, so ignore types you don't recognise. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/nitrosend-webhooks/references/overview.md).

## Delivery Semantics

Return any `2xx` within 10 seconds. Another status, a timeout or a redirect (not followed) is a failed attempt, retried after 1 min, 5 min, 30 min, 2 h, 8 h and 24 h, then marked failed. After 3 days of continuous failure the endpoint is turned off, the account owner is emailed and a `webhook.disabled` event is sent; events that occur while it is off are never sent, even after you re-enable it. Events can repeat and a retry can arrive after later events: deduplicate on `webhook-id` and order by `timestamp`. Return `400` for missing headers and `401` for a bad signature.

## Environment Variables

```bash
NITROSEND_WEBHOOK_SECRET=whsec_xxxxx   # full value returned when the webhook is created; handlers fail closed (500) without it

```

## Local Development

```bash
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 nitrosend --path /webhooks/nitrosend

```

Register the printed URL under **Settings > Webhooks**, then choose **Send test event** to deliver a signed event.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/nitrosend-webhooks/references/overview.md) - Event catalog, envelope and payload fields
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/nitrosend-webhooks/references/setup.md) - Create an endpoint, find the secret, test
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/nitrosend-webhooks/references/verification.md) - Library and manual verification, gotchas, debugging