# Receive webhooks

> Get a signed request on your server when an email is delivered, bounces or is marked as spam.

Source: https://vaartix.com/docs/webhooks/setup/

Instead of polling `GET /emails/{id}`, let Vaartix tell you when something happens. Each event becomes a signed `POST` request to your server, usually within a few seconds.

1.  **Add an endpoint** to your app that accepts `POST` requests, for example `https://api.acme.in/webhooks/vaartix`. It must be public and use HTTPS.

2.  **Register it** with the events you want:

    ```sh
    curl -X POST https://api.vaartix.com/webhooks \
      -H "Authorization: Bearer $VAARTIX_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://api.acme.in/webhooks/vaartix",
        "events": ["email.delivered", "email.bounced", "email.complained"]
      }'
    ```

    The response contains a `secret` starting with `whsec_`. **It’s shown only once**: store it in your secrets manager or environment, for example as `VAARTIX_WEBHOOK_SECRET`.

3.  **Verify every request** with that secret before trusting it. See [Verify signatures](https://vaartix.com/docs/webhooks/verify/).

4.  **Answer quickly** with any `2xx` status, within 10 seconds. Do slow work (sending a Slack message, updating a CRM) after responding, or in a background job.

With the Node.js SDK:

```ts
const { data, error } = await vaartix.webhooks.create({
  url: 'https://api.acme.in/webhooks/vaartix',
  events: ['email.delivered', 'email.bounced', 'email.complained'],
});
// data.secret: store it now, it isn't shown again.
```

## Events

| Event | When |
| --- | --- |
| `email.sent` | Vaartix handed the email to the mail servers. |
| `email.delivered` | The recipient’s mail server accepted it. |
| `email.delivery_delayed` | Delivery is temporarily failing (for example a full mailbox) and is being retried. |
| `email.bounced` | The address doesn’t exist or permanently refused it. Hard bounces are suppressed. |
| `email.complained` | The recipient marked it as spam. The address is suppressed for your team. |
| `email.rejected` | The email was refused before sending, for example because of a virus in an attachment. |
| `email.rendering_failure` | A template couldn’t be rendered. |
| `email.suppressed` | A recipient was skipped because it’s on a suppression list. |
| `email.opened` | The email was opened, with [open tracking](https://vaartix.com/docs/webhooks/setup/#opens-and-clicks) on. |
| `email.clicked` | A link in the email was clicked, with [click tracking](https://vaartix.com/docs/webhooks/setup/#opens-and-clicks) on. |

Events for an email with several recipients come once per recipient, with `data.recipient` set. Emails to [test addresses](https://vaartix.com/docs/sending/test-mode/) produce events too, so you can build and test your endpoint before going live.

## The request

```http
POST /webhooks/vaartix HTTP/1.1
Content-Type: application/json
User-Agent: Vaartix-Webhooks/1.0
webhook-id: whd_8m3k1q7v2c9x4d6p
webhook-timestamp: 1793181600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json
{
  "type": "email.bounced",
  "createdAt": "2026-10-28T10:00:02.000Z",
  "data": {
    "emailId": "em_7hq2kv9xr4m1c8d3",
    "eventId": "evt_2m9k4q7v1c8x3d6p",
    "recipient": "rahul@example.in",
    "from": "Acme <login@acme.in>",
    "to": ["rahul@example.in"],
    "cc": [],
    "bcc": [],
    "subject": "Your login code",
    "tags": [{ "name": "flow", "value": "login" }],
    "emailCreatedAt": "2026-10-28T10:00:00.000Z",
    "details": {
      "bounceType": "Permanent",
      "bounceSubType": "General",
      "diagnosticCode": "smtp; 550 5.1.1 user unknown",
      "statusCode": "5.1.1",
      "diagnostics": ["smtp; 550 5.1.1 user unknown"]
    }
  }
}
```

`details` carries what the mail provider reported, such as the bounce type or SMTP response. For `email.bounced` and `email.delivery_delayed` it includes the receiving server’s reply for that recipient, word for word, in `diagnosticCode`, and its status code in `statusCode`. A delay also has `delayType` and `expirationTime` (when retrying stops):

details of an email.delivery\_delayed event

```json
{
  "delayType": "TransientCommunicationFailure",
  "expirationTime": "2026-11-15T10:00:00.000Z",
  "diagnosticCode": "smtp; 421 4.7.0 [TSS04] Messages from 54.240.0.1 temporarily deferred due to unexpected volume or user complaints - 4.16.55.1; see https://postmaster.yahooinc.com/error-codes",
  "statusCode": "4.7.0"
}
```

See [the receiving server’s reply](https://vaartix.com/docs/concepts/statuses-and-events/#the-receiving-servers-reply) for every field. The full schema is in the [API reference](https://vaartix.com/docs/api/).

### Opens and clicks

`email.opened` and `email.clicked` come only for emails sent with tracking on (the domain’s setting, or `tracking` on the email), and only to endpoints that subscribe to them. Existing endpoints don’t get them until you add the events. Each open or click is one event; repeats are sent too.

```json
{
  "type": "email.clicked",
  "createdAt": "2026-10-28T10:05:41.000Z",
  "data": {
    "emailId": "em_7hq2kv9xr4m1c8d3",
    "eventId": "evt_5k2m8q1v7c3x9d4p",
    "recipient": "rahul@example.in",
    "from": "Acme <orders@acme.in>",
    "to": ["rahul@example.in"],
    "cc": [],
    "bcc": [],
    "subject": "Your order has shipped",
    "tags": [{ "name": "flow", "value": "shipping" }],
    "emailCreatedAt": "2026-10-28T10:00:00.000Z",
    "details": {
      "machine": false,
      "machineReason": null,
      "linkIndex": 0,
      "link": "https://acme.in/orders/123",
      "userAgentFamily": "Chrome",
      "country": "IN",
      "asn": 24560
    }
  }
}
```

`email.opened` has the same `details` without `linkIndex` and `link`.

| Field |  |
| --- | --- |
| `machine` | `true` when it was most likely not a person. Count only `false` ones as people. |
| `machineReason` | Opens: `image_proxy` (Gmail, Yahoo), `apple_mpp` (Apple Mail Privacy Protection), `too_fast` (within 2 seconds of sending). Clicks: `scanner`, `scanner_network`, `too_fast` (within 10 seconds of delivery), `burst` (different links within 1 second). `null` when `machine` is `false`. |
| `linkIndex`, `link` | Clicks only: the link’s position among the tracked links (from 0) and its original URL. |
| `userAgentFamily` | A name such as `Chrome`, `Apple Mail`, `Outlook` or `Gmail image proxy`; `other` or `unknown` otherwise. The full user agent isn’t kept. |
| `country`, `asn` | Where the request came from: a two-letter country code and network number, or `null`. No IP address is kept. |

`recipient` is `null` when the email had more than one recipient, because an open or click can’t be tied to one of them. A click marked `burst` can be marked `machine` only after its webhook was sent, when the next link of the burst comes in; [`GET /emails/:id`](https://vaartix.com/docs/api/) shows the final value.

## Retries

If your server doesn’t answer with a `2xx` within 10 seconds, Vaartix tries again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 6 hours: eight attempts over about a day. Redirects count as failures, so register the final URL.

If 5 deliveries in a row fail every attempt, the endpoint is turned off and its pending deliveries are cancelled. Fix the problem, then turn it back on:

```sh
curl -X PATCH https://api.vaartix.com/webhooks/wh_4n8x2k7q1m9c3v5d \
  -H "Authorization: Bearer $VAARTIX_API_KEY" -H "Content-Type: application/json" \
  -d '{ "status": "enabled" }'
```

## Delivery log

`GET /webhooks/{id}/deliveries` lists recent deliveries with their status, number of attempts and the first 2 KB of your server’s response. To send one again, call `POST /webhooks/{id}/deliveries/{deliveryId}/retry`; it keeps the same `webhook-id`.

## Good practice

-   **Ignore duplicates.** The same delivery can arrive more than once (for example if your server timed out after processing it). Store each `webhook-id` you handle and skip ones you’ve seen.
-   **Don’t rely on order.** Deliveries can arrive out of order. Compare `createdAt` if order matters, and never move an email’s status backwards in your own database.
-   **Respond first, work later.** Queue the event and return `200` straight away.
-   **Rotate secrets** with `POST /webhooks/{id}/rotate-secret` if one may have leaked. The old secret stops working immediately, so update your server first.

Endpoints must be reachable on the public internet. URLs on private networks, `localhost` and plain `http://` are refused. To test against your laptop, use a tunnel such as `cloudflared tunnel --url http://localhost:3000` or ngrok and register the HTTPS address it gives you.
