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.

FieldTypeDescription
idstring (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_atstring (ISO 8601)When the event happened (ISO 8601, UTC).
dataobjectItems on this page.
data.email_idstring (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.fromstringSender. a@b.com or Name <a@b.com>. The domain must be verified on your account, or be your sandbox domain.
data.tostring[]One recipient or a list. At most 50 recipients across to, cc and bcc.
data.subjectstringSubject line. Line breaks are rejected.
data.tagsstring[]Up to 10 tags of letters, numbers, _ - : . for filtering and reporting.
data.statusstringThe 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.detailsobject<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.

HTTP
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

TypeWhen it firesStatus after
email.sentThe provider accepted the message and it has left our queue. This is the first event a webhook receives for a live send.sent
email.deliveredThe recipient's mail server accepted the message.delivered
email.delivery_delayedThe 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.bouncedThe 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.complainedThe 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.rejectedThe provider refused the message before sending, for example a blocked attachment type. No retry.failed
email.failedWe could not hand the message to the provider after retries, or the account was paused. No retry.failed
email.cancelledA 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.detailsMeaning
ses_message_idThe 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.detailsMeaning
ses_message_idThe provider's message id, also on the email record.
recipientsAddresses 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.detailsMeaning
ses_message_idThe provider's message id, also on the email record.
delay_typeWhy it was deferred, as reported by the provider (e.g. MailboxFull, TransientCommunicationFailure).
expires_atWhen retries stop and the message bounces if still undelivered.
recipientsAddresses affected.
diagnostic_codeThe 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.detailsMeaning
ses_message_idThe provider's message id, also on the email record.
bounce_typePermanent, Transient or Undetermined.
bounce_sub_typeThe provider's finer classification (e.g. General, NoEmail, MailboxFull).
recipientsAddresses that bounced.
diagnostic_codeThe 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.detailsMeaning
ses_message_idThe provider's message id, also on the email record.
recipientsAddresses that complained.
feedback_typeThe 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.detailsMeaning
ses_message_idThe provider's message id, also on the email record.
reasonWhy 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.detailsMeaning
errorWhat went wrong.
attemptsHow many send attempts were made.
retryableWhether 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": {}
  }
}

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:

TypeScript
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:

TypeScript
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);
}
Use the raw body
Compute the HMAC over the exact bytes you received. Parsing the JSON and serialising it again changes whitespace and key order, and the signature will not match.

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.