Webhooks
webhook · notifications · callbacks · email.delivered · email.bounced · email.complained · email.opened · email.clicked · email.sent · webhook.test · signature verification · webhook-signature · webhook-id · webhook-timestamp · whsec_ · Standard Webhooks · Svix
A webhook is an address on your server that Azimea calls when something happens to one of your emails: it was delivered, it bounced, the person opened it or clicked a link. Each notification is signed, so your server can prove it comes from Azimea before acting on it.
Add a webhook
Section titled “Add a webhook”Only the owner and admins of the company can do this.
- In the dashboard, open Settings › API keys and scroll to Webhooks.
- Click Add webhook.
- In Address, enter the URL on your server that receives the notifications. It must start with
https://. - Under Events, tick the events you want. Leave all of them unticked to receive every event, including the ones added later.
- Click Add webhook. The signing secret (it starts with
whsec_) appears once, above the list: copy it now and keep it with your server’s secrets. Azimea never shows it again; if you lose it, delete the webhook and add it again. - Click Send test next to the new webhook. Azimea sends a
webhook.testnotification right away and tells you whether your server answered with a2xx.
A company can have up to 20 active webhooks. To stop one, click Delete: notifications stop at once, including those still waiting to be retried.
The events
Section titled “The events”| Event | When it is sent |
|---|---|
email.sent |
The email left Azimea for the sending server. It does not mean it arrived yet. |
email.delivered |
The recipient’s mail server received it. |
email.bounced |
The recipient’s mail server refused it for good. data.reason says why. |
email.complained |
The person marked it as spam. |
email.opened |
The email was opened for the first time. |
email.clicked |
A link in it was clicked for the first time. data.url is the link. |
webhook.test |
A test, asked for to check that your address answers. |
A webhook can listen to some of these events or, with an empty list, to all of them. Each event is sent at most once per email: the first open and the first click, not every one. An open or a click may come from a mail privacy feature or a link scanner rather than a person.
What arrives
Section titled “What arrives”A POST with a JSON body and three headers:
POST /azimea HTTP/1.1Content-Type: application/jsonwebhook-id: 01a10912-5b3c-7d41-9e2f-6b8c1d0a4f77webhook-timestamp: 1791198664webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"id":"01a10912-5b3c-7d41-9e2f-6b8c1d0a4f77","type":"email.delivered","created_at":"2026-10-05T09:31:04.1180000+00:00","data":{"message_id":"01a10900-0000-7000-8000-000000000301","email":"ana@example.com"}}| Field | What it is |
|---|---|
id |
The notification’s id, the same as the webhook-id header. Use it to ignore a notification you already handled. |
type |
The event, from the table above. |
created_at |
When it happened, ISO 8601 in UTC. |
data.message_id |
The email: the id you got back from POST /sending/emails, or the id of a campaign or flow email. |
data.email |
The recipient’s address. |
data.contact_id |
The contact in Azimea, when the email went to a contact (campaigns and flows). |
data.source_id |
The campaign or flow that sent it, when there is one. |
data.url |
email.clicked only: the link. |
data.reason |
email.bounced, email.complained and webhook.test: the detail Azimea received. |
Fields without a value for the event are left out, not sent empty. The names are in snake_case.
Answer quickly
Section titled “Answer quickly”Answer with any 2xx status within 10 seconds, then do the work. Anything else — another status, a timeout, a
connection that cannot be made — counts as a failure, and the notification is sent again with the same body and
the same webhook-id:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | 1 minute after the first failure |
| 3 | 5 minutes later |
| 4 | 15 minutes later |
| 5 | 1 hour later |
| 6 | 3 hours later, the last one |
After 15 failed deliveries in a row, the webhook is stopped: the dashboard shows it as Stopped, with the reason.
Fix your server, then delete the webhook and add it again. Redirects are not followed, and addresses on private
networks are refused: give the final public https address.
Because of retries, a notification can arrive more than once: keep the webhook-ids you handled and skip repeats.
Notifications about the same email are not guaranteed to arrive in order.
Verify the signature
Section titled “Verify the signature”The scheme is Standard Webhooks, so any Standard Webhooks library verifies it with your secret. By hand:
- Read the raw body, exactly as it arrived — before any JSON parsing, which would change spaces and order.
- Refuse the notification if
webhook-timestamp(Unix seconds) is more than 5 minutes away from now, in the past or the future: it is a replay. - Your secret looks like
whsec_followed by base64. The signing key is the part afterwhsec_, decoded from base64. - Compute HMAC-SHA256 with that key over
{webhook-id}.{webhook-timestamp}.{raw body}, and encode it in base64. webhook-signatureholds one or more signatures separated by spaces, each writtenv1,<base64>. Accept the notification if one of them equals yours, compared in constant time.
using System.Security.Cryptography;using System.Text;
static bool IsFromAzimea(string secret, string id, string timestamp, string signatures, string rawBody){ if (!long.TryParse(timestamp, out var sentAt) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sentAt) > 300) { return false; }
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret); var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
foreach (var candidate in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { if (!candidate.StartsWith("v1,")) { continue; }
var given = new byte[candidate.Length]; if (Convert.TryFromBase64String(candidate[3..], given, out var written) && CryptographicOperations.FixedTimeEquals(expected, given.AsSpan(0, written))) { return true; } }
return false;}
// ASP.NET Core: read the body as text before anything parses it.app.MapPost("/azimea", async (HttpRequest request) =>{ var rawBody = await new StreamReader(request.Body).ReadToEndAsync(); var valid = IsFromAzimea( Environment.GetEnvironmentVariable("AZIMEA_WEBHOOK_SECRET")!, request.Headers["webhook-id"].ToString(), request.Headers["webhook-timestamp"].ToString(), request.Headers["webhook-signature"].ToString(), rawBody);
return valid ? Results.Ok() : Results.Unauthorized();});import crypto from 'node:crypto';import express from 'express';
function isFromAzimea(secret, id, timestamp, signatures, rawBody) { if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(' ').some((candidate) => { if (!candidate.startsWith('v1,')) return false; const given = Buffer.from(candidate.slice(3), 'base64'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); });}
const app = express();
// express.raw keeps the body as it arrived: express.json() would parse it first.app.post('/azimea', express.raw({ type: 'application/json' }), (req, res) => { const valid = isFromAzimea( process.env.AZIMEA_WEBHOOK_SECRET, req.header('webhook-id') ?? '', req.header('webhook-timestamp') ?? '', req.header('webhook-signature') ?? '', req.body.toString('utf8'), ); if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // handle event.type here, after answering});function is_from_azimea(string $secret, string $id, string $timestamp, string $signatures, string $rawBody): bool{ if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; }
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true); if ($key === false) { return false; }
$expected = hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true);
foreach (explode(' ', $signatures) as $candidate) { if (str_starts_with($candidate, 'v1,')) { $given = base64_decode(substr($candidate, 3), true); if ($given !== false && hash_equals($expected, $given)) { return true; } } }
return false;}
$rawBody = file_get_contents('php://input');$valid = is_from_azimea( getenv('AZIMEA_WEBHOOK_SECRET'), $_SERVER['HTTP_WEBHOOK_ID'] ?? '', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $rawBody);
http_response_code($valid ? 200 : 401);import base64, hashlib, hmac, os, timefrom flask import Flask, request
def is_from_azimea(secret: str, msg_id: str, timestamp: str, signatures: str, raw_body: bytes) -> bool: if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: return False
key = base64.b64decode(secret.removeprefix("whsec_")) expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
for candidate in signatures.split(" "): if candidate.startswith("v1,"): try: given = base64.b64decode(candidate[3:], validate=True) except ValueError: continue if hmac.compare_digest(expected, given): return True return False
app = Flask(__name__)
@app.post("/azimea")def azimea(): valid = is_from_azimea( os.environ["AZIMEA_WEBHOOK_SECRET"], request.headers.get("webhook-id", ""), request.headers.get("webhook-timestamp", ""), request.headers.get("webhook-signature", ""), request.get_data(), # the raw body, before any parsing ) return ("", 200) if valid else ("", 401)The secret is shown once, when the webhook is created. Keep it with your other secrets, never in client-side code.
Test notifications
Section titled “Test notifications”When you click Send test, Azimea sends it a webhook.test notification, signed like any other, to confirm that the
address answers and that your verification accepts it. It carries a made-up message_id and the address
proba@azimea.com: answer 2xx and otherwise ignore it.
From a sandbox
Section titled “From a sandbox”Emails sent from a sandbox never reach anyone, but they are marked sent and delivered, and their email.sent and
email.delivered notifications are sent like real ones: an integration can be tested end to end with an azm_test_
key.