Webhook events
Every delivery to an endpoint is a JSON POST with the shape below, signed with the Avelto-Signature header. Subscribe an endpoint to some or all of the event types when you create it; the default is all of them. Deliveries are attempted up to eight times with backoff, so handle each event id at most once.
Payload
The same envelope for every event. Field tables on this page are generated from the schema the API validates against.
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The event id. Deliveries are retried, so use it to de-duplicate. |
type | "email.queued" | "email.scheduled" | "email.sent" | "email.delivered" | "email.delivery_delayed" | "email.bounced" | "email.complained" | "email.rejected" | "email.failed" | "email.cancelled" | One of the event types below. |
created_at | string (ISO 8601) | When the event happened (ISO 8601, UTC). |
data | object | Items on this page. |
data.email_id | string (uuid) | The email the event belongs to. Fetch it with GET /v1/emails/:id. |
data.mode | "live" | "test" | live or test. Test-mode sends produce real webhooks. |
data.from | string | Sender. a@b.com or Name <a@b.com>. The domain must be verified on your account, or be your sandbox domain. |
data.to | string[] | One recipient or a list. At most 50 recipients across to, cc and bcc. |
data.subject | string | Subject line. Line breaks are rejected. |
data.tags | string[] | Up to 10 tags of letters, numbers, _ - : . for filtering and reporting. |
data.status | string | The email's status when this delivery was sent. On a retry or a send-again it can be later than the event itself; use type for what happened and status for where the email is now. |
data.details | object<string, any> | Event-specific fields, listed per event below. |
Test-mode sends produce the same events with details.test: true and a synthetic message id.
POST /hooks/avelto HTTP/1.1
Content-Type: application/json
Avelto-Signature: t=1758103929,v1=5f1c2a9b7e3d4c6f8a0b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a
{
"id": "e4b1c7d2-8f3a-4c5b-9d6e-0a1b2c3d4e5f",
"type": "email.delivered",
"created_at": "2026-09-17T10: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"] }
}
}Events
| 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. |
{
"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. |
{
"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. |
{
"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. |
{
"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). |
{
"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. |
{
"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. |
{
"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.
{
"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": {}
}
}Signature header
Avelto-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 over the string <t>.<raw body> using the endpoint secret returned once at creation. Reject requests whose t is more than five minutes from the current time.
Two more headers come with every request: Avelto-Event-Id, which is the same on every retry of an event, and Avelto-Delivery-Id, which is new for a send-again.
Verify signatures
With the Node SDK:
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, in plain Node:
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);
}Other languages follow the same three steps: split the header, check the timestamp, compare a constant-time HMAC. There is a Python example on the Webhooks page.