# 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**

```bash
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"
    ]
  }'
```

**Node**

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

const avelto = new Avelto(process.env.AVELTO_API_KEY!);

const endpoint = await avelto.webhooks.create({
  url: "https://acme.com/hooks/avelto",
  events: ["email.delivered", "email.bounced", "email.complained"],
});
console.log(endpoint.secret); // shown once; store it
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.avelto.dev/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
    json={
      "url": "https://acme.com/hooks/avelto",
      "events": [
        "email.delivered",
        "email.bounced",
        "email.complained"
      ]
    },
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

import (
	"bytes"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body := []byte(`{"url":"https://acme.com/hooks/avelto","events":["email.delivered","email.bounced","email.complained"]}`)
	req, _ := http.NewRequest("POST", "https://api.avelto.dev/v1/webhooks", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

**Ruby**

```ruby
require "net/http"
require "json"

uri = URI("https://api.avelto.dev/v1/webhooks")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
  url: "https://acme.com/hooks/avelto",
  events: ["email.delivered", "email.bounced", "email.complained"]
})

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
puts res.code, res.body
```

**PHP**

```php
<?php

require "vendor/autoload.php";

$client = new GuzzleHttp\Client(["base_uri" => "https://api.avelto.dev"]);

$res = $client->request("POST", "/v1/webhooks", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
    "json" => [
        "url" => "https://acme.com/hooks/avelto",
        "events" => ["email.delivered", "email.bounced", "email.complained"]
    ],
]);

echo $res->getStatusCode(), "\n", $res->getBody();
```

**C#**

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AVELTO_API_KEY"));

var res = await client.PostAsJsonAsync("https://api.avelto.dev/v1/webhooks", new
{
    url = "https://acme.com/hooks/avelto",
    events = new[] { "email.delivered", "email.bounced", "email.complained" }
});
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

```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](/docs/webhooks/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 <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](/docs/webhooks/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:

```ts
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**

```bash
curl "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50" \
  -H "Authorization: Bearer av_live_..."
```

**Node**

```ts
const page = await avelto.webhooks.listDeliveries("7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b", { limit: 50 });
for (const d of page.data) console.log(d.event_type, d.status, d.attempts);
```

**Python**

```python
import os, requests

r = requests.get(
    "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

**Ruby**

```ruby
require "net/http"
require "json"

uri = URI("https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
puts res.code, res.body
```

**PHP**

```php
<?php

require "vendor/autoload.php";

$client = new GuzzleHttp\Client(["base_uri" => "https://api.avelto.dev"]);

$res = $client->request("GET", "/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
]);

echo $res->getStatusCode(), "\n", $res->getBody();
```

**C#**

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AVELTO_API_KEY"));

var res = await client.GetAsync("https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries?limit=50");
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

**curl**

```bash
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_..."
```

**Node**

```ts
await avelto.webhooks.retryDelivery("7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b", "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f");
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("POST", "https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

**Ruby**

```ruby
require "net/http"
require "json"

uri = URI("https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
puts res.code, res.body
```

**PHP**

```php
<?php

require "vendor/autoload.php";

$client = new GuzzleHttp\Client(["base_uri" => "https://api.avelto.dev"]);

$res = $client->request("POST", "/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
]);

echo $res->getStatusCode(), "\n", $res->getBody();
```

**C#**

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AVELTO_API_KEY"));

var res = await client.PostAsync("https://api.avelto.dev/v1/webhooks/7a2c9e41-3b5d-4f60-9e8a-1c2d3e4f5a6b/deliveries/0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f/retry");
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

`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.

---

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