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.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" ] }'{ "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. A new endpoint starts with enabled: true, and endpoints are never paused automatically.
To change which events an endpoint receives, or to switch it off, send PATCH /v1/webhooks/:id with events, enabled or both; anything you leave out stays as it is. Switching an endpoint off stops its deliveries at once, and any already waiting in the queue are dropped; switch it back on to receive new events again. The URL cannot be changed: a new URL is a new endpoint, with its own secret.
An endpoint receives every event it subscribes to in both modes, live and test, so creating, changing or deleting one needs a live key (or the dashboard). A test key gets 403 live_key_required, but can still list endpoints and read and retry the deliveries of test events.
An account can have up to 10 endpoints; the eleventh is refused with 403 plan_limit. One endpoint can subscribe to as many event types as you like, so this is rarely the limit you reach.
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
POST /hooks/avelto HTTP/1.1Content-Type: application/jsonAvelto-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"] } }}| Field | Meaning |
|---|---|
id | The event id. Use it to de-duplicate: a retry carries the same id. |
type | One of the eight event types. |
created_at | When the event happened. |
data.email_id | The email. Fetch it with GET /v1/emails/:id for the full record. |
data.mode | live or test. Test-mode sends produce real webhooks. |
data.from, data.to, data.subject, data.tags | Copied from the email so most handlers need no extra request. |
data.status | 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 | Event-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:
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.
signed_payload = "<t>.<raw request body>"expected = hex(HMAC-SHA256(secret, signed_payload))valid = constant_time_equal(expected, v1) and |now - t| <= 300Two 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:
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:
import json, osfrom flask import Flask, request, abortfrom avelto import verify_webhook_signatureapp = Flask(__name__)@app.post("/hooks/avelto")def avelto_hook(): body = request.get_data() # the raw bytes, before any parsing if not verify_webhook_signature(os.environ["AVELTO_WEBHOOK_SECRET"], body, request.headers.get("Avelto-Signature")): abort(400) event = json.loads(body) if event["type"] == "email.bounced": print("bounced", event["data"]["email_id"], event["data"]["details"]) return "", 200Respond 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.
At most two deliveries are in progress to one endpoint at a time, and five across all of an account's endpoints; the rest wait their turn, which does not count as an attempt. A slow endpoint therefore delays its own events, not anyone else's.
Deliveries
Every attempt is recorded. List them per endpoint, newest first, and retry a failed one.
curl "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50" \ -H "Authorization: Bearer av_live_..."curl -X POST https://api.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.
Every attempt is logged on its own as well, with the response your endpoint gave that time. GET /v1/webhooks/:id/deliveries/:deliveryId/attempts returns them oldest first:
{ "data": [ { "id": "0f6b2d1e-3c4a-4e8b-9a71-5d2c8e4f1a03", "delivery_id": "4b1e7c9a-2d3f-4a6b-8c5e-1f0a9d7b3e62", "attempt": 1, "started_at": "2026-10-06T10:00:00.000Z", "duration_ms": 10000, "response_code": null, "response_excerpt": null, "response_truncated": false, "error": "timeout" }, { "id": "8a3c5e7f-1b2d-4c6e-9f0a-3b5d7e9f1c24", "delivery_id": "4b1e7c9a-2d3f-4a6b-8c5e-1f0a9d7b3e62", "attempt": 2, "started_at": "2026-10-06T10:00:15.000Z", "duration_ms": 84, "response_code": 200, "response_excerpt": "{\"received\":true}", "response_truncated": false, "error": null } ]}error is null on success. A failed attempt with a response carries its status, such as HTTP 500; one with no response has a null response_code and an error saying why: timeout, could not connect (the connection was refused or reset, the name did not resolve, or the TLS handshake failed, for example on an expired certificate), or blocked address when the URL resolved to an address webhooks may not reach. These are deliberately coarse, so an endpoint URL cannot be used to probe which ports answer on someone else's host. response_excerpt is the first 1 KB of the body your endpoint returned, and response_truncated is true when there was more. A retry keeps adding to the same list. Attempts are kept for your plan's log retention period.
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 every attempt with its time, duration and status code or error (open one to see the start of what your endpoint returned), the exact body we sent, the Avelto-Signature header that came with it, and the body your endpoint sent back last, 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.deliveredpayload 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 hasmode: "test",details.test: true,tags: ["test-event"]and anemail_idthat 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, and send again to thirty. 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. Only a delivery whose status is failed can be retried: one that was delivered, or is still being retried automatically, gets 409 conflict and nothing is sent. Retries are limited to 100 per hour per endpoint (429 rate_limited with a retry-after header beyond that); a refused one does not count.
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.