Webhooks

A webhook endpoint receives a JSON POST for every event it subscribes to. Each request is signed so you can verify it came from Avelto, and failed deliveries are retried.

Create an endpoint

curl -X POST https://api-staging.avelto.dev/v1/webhooks \
  -H "Authorization: Bearer av_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/hooks/avelto",
    "events": [
      "email.delivered",
      "email.bounced",
      "email.complained"
    ]
  }'
JSON
{
  "id": "7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b",
  "url": "https://acme.com/hooks/avelto",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "enabled": true,
  "created_at": "2026-09-17T10:00:00.000Z",
  "secret": "whsec_..."
}

The secret is returned once, at creation. Store it; you need it to verify signatures. In production the URL must be https and must resolve to a public address. enabled is always true today; endpoints are not paused automatically.

events is optional. When omitted the endpoint is subscribed to all eight event types.

Event types

There are eight subscribable event types, from email.sent through email.delivered, email.bounced and email.complained to email.cancelled. The Webhook events page lists all of them with when each fires, the status it leaves the email in, and the exact details it carries.

Payload

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 <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Receipt #1042",
    "tags": ["receipt"],
    "status": "delivered",
    "details": { "ses_message_id": "0100019...", "recipients": ["[email protected]"] }
  }
}
FieldMeaning
idThe event id. Use it to de-duplicate: a retry carries the same id.
typeOne of the eight event types.
created_atWhen the event happened.
data.email_idThe email. Fetch it with GET /v1/emails/:id for the full record.
data.modelive or test. Test-mode sends produce real webhooks.
data.from, data.to, data.subject, data.tagsCopied from the email so most handlers need no extra request.
data.statusThe 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.detailsEvent-specific fields. Every provider event carries ses_message_id; bounces add the bounce type and diagnostic code, deliveries the recipients. Test-mode events carry test: true. See the per-event tables on the Webhook events page.

Verify the signature

Every request carries an Avelto-Signature header:

text
Avelto-Signature: t=1758103929,v1=5f1c2a9b7e3d…

t is a Unix timestamp in seconds; v1 is the hex HMAC-SHA256 of "<t>.<raw body>" using your endpoint secret. Verify before you parse, and reject anything older than five minutes.

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.

text
signed_payload = "<t>.<raw request body>"
expected      = hex(HMAC-SHA256(secret, signed_payload))
valid         = constant_time_equal(expected, v1) and |now - t| <= 300

Two rules keep this reliable: compute the HMAC over the raw request body, byte for byte, before any JSON parsing, and compare with a constant-time function.

With the Node SDK and Express:

TypeScript
import express from "express";
import { verifyWebhookSignature, type WebhookPayload } from "@avelto/sdk";

const app = express();

const secret = process.env.AVELTO_WEBHOOK_SECRET;

app.post("/hooks/avelto", express.raw({ type: "application/json" }), async (req, res) => {
  const body = req.body.toString("utf8");
  const signature = req.header("Avelto-Signature");
  const ok = await verifyWebhookSignature(secret, body, signature);
  if (!ok) return res.status(400).send("invalid signature");

  const event = JSON.parse(body) as WebhookPayload;
  if (event.type === "email.bounced") {
    console.log("bounced", event.data.email_id, event.data.details);
  }
  res.sendStatus(200);
});

In Python:

Python
import hmac, hashlib, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = int(parts["t"]), parts["v1"]
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Respond and retry

Return any 2xx within 10 seconds. Anything else, or a timeout, counts as a failure. Redirects are not followed: a 3xx is a failure too. Failed deliveries are retried with exponential backoff starting at 5 seconds, up to eight attempts in total over about ten minutes. After the last failure the delivery is marked failed and you can retry it by hand.

Because retries can overlap with a late success, handlers must be idempotent. De-duplicate on the event id.

Deliveries

Every attempt is recorded. List them per endpoint, newest first, and retry a failed one.

curl "https://api-staging.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50" \
  -H "Authorization: Bearer av_live_..."
curl -X POST https://api-staging.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry \
  -H "Authorization: Bearer av_live_..."

status is pending, delivered or failed; attempts counts tries so far and last_error holds the most recent failure. Each delivery also records the response code your endpoint returned and how long it took.

Debugging a handler

These are dashboard tools rather than API routes. They exist to help you get a handler working, which is something you do by hand, so they live in the dashboard and are not part of the versioned API.

The webhook page in your dashboard opens each endpoint's full delivery history. Expanding a delivery shows the exact body we sent, the Avelto-Signature header that came with it, and the body your endpoint sent back, truncated if it was very large. That is usually enough to tell a signature problem from a parsing problem without reproducing anything.

Two buttons help while you are still wiring things up:

  • Send test event posts a synthetic email.delivered payload to your endpoint. It is signed exactly like a real delivery, so your verification code is being tested too, and it needs no real mail to have been sent. The payload has mode: "test", details.test: true, tags: ["test-event"] and an email_id that does not exist, so a handler can recognise it and skip any lookup. Use it to build your handler before your first send.
  • Send again delivers an event you have already had, as a new delivery. It appears at the top of the history marked "sent again", so you can tell it apart from the original, and it goes through the same signing path.

Send test event is limited to ten per hour per endpoint. Both go through the same URL checks and signing as a real delivery.

Retry vs send again

Both put a delivery back on the queue, and they answer different questions.

Retry re-attempts a delivery that failed. There is one delivery throughout: its attempt count keeps climbing and its record shows every try, so the history reads as one event that took several goes to get through. Use it when the failure was transient and your endpoint is healthy again. It is on the API and in the SDK, because that is usually a script: after an outage you walk your failed deliveries and re-enqueue them.

Send again creates a new delivery for the same event, and the original is left exactly as it was. Use it when you have changed your handler and want to see the fixed code receive a past event as if it had just happened. It is a button in the dashboard and nothing else, because deciding that a handler is now correct is a judgement someone makes rather than something a program schedules.

The short version: retry is recovery, send again is testing. If you want the history to say "this event eventually got through", retry. If you want a clean delivery against new code, send again.

Local development

Webhook URLs must be public. To receive events on your machine, expose a local port with a tunnel and register the tunnel URL, or use GET /v1/emails/:id to read the event log instead.