# Webhook events

Every delivery is a JSON POST signed with the `Avelto-Signature` header (`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`). Reject requests whose `t` is more than five minutes old. Retried up to eight times, so de-duplicate on the event `id`.

| Type | When it fires | Status after |
| --- | --- | --- |
| `email.sent` | The provider accepted the message and it has left our queue. This is the first event a webhook receives for a live send. | `sent` |
| `email.delivered` | The recipient's mail server accepted the message. | `delivered` |
| `email.delivery_delayed` | The recipient's server deferred the message (mailbox full, greylisting, a temporary error). Delivery is retried automatically; a later email.delivered or email.bounced closes it. | unchanged |
| `email.bounced` | The message could not be delivered. Permanent bounces add the address to your suppression list (live sends only; simulator and test-mode addresses are never suppressed). | `bounced` |
| `email.complained` | The recipient marked the message as spam. The address is added to your suppression list (live sends only; simulator and test-mode addresses are never suppressed). | `complained` |
| `email.rejected` | The provider refused the message before sending, for example a blocked attachment type. No retry. | `failed` |
| `email.failed` | We could not hand the message to the provider after retries, or the account was paused. No retry. | `failed` |
| `email.cancelled` | A scheduled email was cancelled through the API or the dashboard before it was sent. | `cancelled` |

## email.sent

The provider accepted the message and it has left our queue. This is the first event a webhook receives for a live send.

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |

```json
{
  "id": "a7c2e9d1-3f4b-4a8e-b6c0-2d9e1f7b5c34",
  "type": "email.sent",
  "created_at": "2026-09-18T10:12:05.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "sent",
    "details": {
      "ses_message_id": "0100019..."
    }
  }
}
```

## email.delivered

The recipient's mail server accepted the message.

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |
| `recipients` | Addresses the delivery applies to. |

```json
{
  "id": "c4b8d2f6-7e1a-4d3c-8f9b-6a2e0c5d1b78",
  "type": "email.delivered",
  "created_at": "2026-09-18T10:12:09.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "delivered",
    "details": {
      "ses_message_id": "0100019...",
      "recipients": [
        "jane@example.com"
      ]
    }
  }
}
```

## email.delivery_delayed

The recipient's server deferred the message (mailbox full, greylisting, a temporary error). Delivery is retried automatically; a later email.delivered or email.bounced closes it.

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |
| `delay_type` | Why it was deferred, as reported by the provider (e.g. MailboxFull, TransientCommunicationFailure). |
| `expires_at` | When retries stop and the message bounces if still undelivered. |
| `recipients` | Addresses affected. |
| `diagnostic_code` | The remote server's response, when available. |

```json
{
  "id": "d2e5f8a1-4b7c-4d0e-a3f6-9b8c7d6e5f40",
  "type": "email.delivery_delayed",
  "created_at": "2026-09-18T10:12:30.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "sent",
    "details": {
      "ses_message_id": "0100019...",
      "delay_type": "MailboxFull",
      "expires_at": "2026-09-19T10:12:05.000Z",
      "recipients": [
        "jane@example.com"
      ],
      "diagnostic_code": "452 4.2.2 Mailbox full"
    }
  }
}
```

## email.bounced

The message could not be delivered. Permanent bounces add the address to your suppression list (live sends only; simulator and test-mode addresses are never suppressed).

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |
| `bounce_type` | Permanent, Transient or Undetermined. |
| `bounce_sub_type` | The provider's finer classification (e.g. General, NoEmail, MailboxFull). |
| `recipients` | Addresses that bounced. |
| `diagnostic_code` | The remote server's response, when available. |

```json
{
  "id": "e9f0a1b2-c3d4-4e5f-8a9b-0c1d2e3f4a5b",
  "type": "email.bounced",
  "created_at": "2026-09-18T10:12:12.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "bounced",
    "details": {
      "ses_message_id": "0100019...",
      "bounce_type": "Permanent",
      "bounce_sub_type": "General",
      "recipients": [
        "jane@example.com"
      ],
      "diagnostic_code": "smtp; 550 5.1.1 user unknown"
    }
  }
}
```

## email.complained

The recipient marked the message as spam. The address is added to your suppression list (live sends only; simulator and test-mode addresses are never suppressed).

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |
| `recipients` | Addresses that complained. |
| `feedback_type` | The complaint category reported by the mailbox provider (e.g. abuse). |

```json
{
  "id": "f1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
  "type": "email.complained",
  "created_at": "2026-09-18T10:12:40.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "complained",
    "details": {
      "ses_message_id": "0100019...",
      "recipients": [
        "jane@example.com"
      ],
      "feedback_type": "abuse"
    }
  }
}
```

## email.rejected

The provider refused the message before sending, for example a blocked attachment type. No retry.

| data.details | Meaning |
| --- | --- |
| `ses_message_id` | The provider's message id, also on the email record. |
| `reason` | Why it was refused. |

```json
{
  "id": "0a1b2c3d-4e5f-4607-8899-aabbccddeeff",
  "type": "email.rejected",
  "created_at": "2026-09-18T10:12:06.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "failed",
    "details": {
      "ses_message_id": "0100019...",
      "reason": "Bad content"
    }
  }
}
```

## email.failed

We could not hand the message to the provider after retries, or the account was paused. No retry.

| data.details | Meaning |
| --- | --- |
| `error` | What went wrong. |
| `attempts` | How many send attempts were made. |
| `retryable` | Whether the last error was a retryable (throttling) error. |

```json
{
  "id": "1b2c3d4e-5f60-4718-8a9b-bccddeeff001",
  "type": "email.failed",
  "created_at": "2026-09-18T10:12:20.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "failed",
    "details": {
      "error": "Sending is paused on this account",
      "attempts": 1,
      "retryable": false
    }
  }
}
```

## email.cancelled

A scheduled email was cancelled through the API or the dashboard before it was sent.

```json
{
  "id": "2c3d4e5f-6071-4829-9bac-cddeeff00112",
  "type": "email.cancelled",
  "created_at": "2026-09-18T10:12:00.000Z",
  "data": {
    "email_id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",
    "mode": "live",
    "from": "Acme <billing@mail.acme.com>",
    "to": [
      "jane@example.com"
    ],
    "subject": "Receipt #1042",
    "tags": [
      "receipt"
    ],
    "status": "cancelled",
    "details": {}
  }
}
```

## Verify signatures

With the Node SDK:

```ts
import { verifyWebhookSignature } from "@avelto/sdk";

// Inside your HTTP handler. `rawBody` must be the exact bytes received.
const ok = await verifyWebhookSignature(
  process.env.AVELTO_WEBHOOK_SECRET,
  rawBody,
  request.headers["avelto-signature"],
);
if (!ok) return new Response("invalid signature", { status: 400 });

const event = JSON.parse(rawBody);
console.log(event.type, event.data.email_id);
```

Without the SDK:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

---

Rendered page: https://staging.avelto.dev/docs/webhooks/events
