TL;DR
- Add
"callback_url"(https only) when you create a list. When it finishes we POST{"event": "list.done", "list": {...}}to it. - Each call carries
X-MX-Signature: the hex HMAC-SHA256 of the raw body using your account's webhook secret. Verify it before you trust anything. - Answer with any 2xx quickly. Failures are retried after 30 seconds and after 5 minutes, then we stop.
Why use a callback
Polling works, but it wastes calls and delays your pipeline by up to one poll interval. With a callback your system is told the moment a list is done, and the usual follow-up (download the results, update your CRM, start a campaign) runs right away. It also suits serverless setups where nothing is running to poll. You still need an API key, issued on request via the contact page, and the endpoint reference is on the API page.
Register a callback
Pass callback_url when you submit a list. It must start with https://, otherwise the call fails with 400.
curl -X POST https://mailexchange.com/api/v1/lists \
-H "Authorization: Bearer $MAILEXCHANGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["[email protected]", "[email protected]"], "callback_url": "https://you.example/mx-hook"}'
The callback is set per list, so different jobs can notify different endpoints. There is nothing to configure in a dashboard.
What we send
One event exists today, list.done. The body is JSON with the event name and the same summary you get from GET /lists/{id} (the numbers here are an example):
POST /mx-hook HTTP/1.1
Content-Type: application/json
User-Agent: MailExchange-Webhook/1
X-MX-Signature: 9f2c...64 hex characters...e1
{"event":"list.done","list":{"id":4821,"name":"october-leads","status":"done","total":2,"duplicates_removed":1,"checked":2,"counts":{"deliverable":1,"risky":0,"undeliverable":1,"unknown":0},"created_at":1792310400,"finished_at":1792310460}}
The payload tells you which list finished and how it came out. It does not include the per-address rows. Fetch those from /lists/{id}/results, as described in verify a list with the API.
Verify the signature
The signature is the lowercase hex HMAC-SHA256 of the body, keyed with your account's webhook secret. Two details matter:
- Use the raw bytes. Compute the HMAC over the body exactly as received. If your framework parses the JSON and you serialise it again, whitespace and key order can differ and the check will fail.
- Compare in constant time. Use
hmac.compare_digest,timingSafeEqualorhash_equals, never==.
Keep the secret in an environment variable. If you do not have your webhook secret, ask us.
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["MAILEXCHANGE_WEBHOOK_SECRET"].encode()
@app.post("/mx-hook")
def mx_hook():
raw = request.get_data() # the exact bytes we sent, not re-serialised JSON
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-MX-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
if event["event"] == "list.done":
list_id = event["list"]["id"]
print("list done", list_id, event["list"]["counts"])
# queue a job here that fetches /lists/{list_id}/results, then answer fast
return "", 204
Node.js (no packages)
With Express, mount express.raw({type: "application/json"}) on this route so you receive the Buffer, and pass it to the same check.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.MAILEXCHANGE_WEBHOOK_SECRET;
function validSignature(rawBody, header) {
const expected = Buffer.from(createHmac("sha256", SECRET).update(rawBody).digest("hex"));
const received = Buffer.from(header ?? "");
return expected.length === received.length && timingSafeEqual(expected, received);
}
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/mx-hook") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks); // the exact bytes we sent
if (!validSignature(rawBody, req.headers["x-mx-signature"])) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody.toString("utf8"));
if (event.event === "list.done") {
console.log("list done", event.list.id, event.list.counts);
// queue a job here that fetches /lists/{id}/results, then answer fast
}
res.writeHead(204).end();
});
}).listen(3000);
PHP
<?php
// mx-hook.php
$raw = file_get_contents('php://input'); // the exact bytes we sent
$expected = hash_hmac('sha256', $raw, getenv('MAILEXCHANGE_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_MX_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if (($event['event'] ?? '') === 'list.done') {
$listId = $event['list']['id'];
// queue a job here that fetches /lists/{$listId}/results, then answer fast
}
http_response_code(204);
The same few lines work in any language. For Go, Ruby, Java or C# read the body into bytes, compute HMAC-SHA256 with the standard crypto library, hex encode, and compare in constant time. Language guides: Python, Node.js, PHP, Ruby, Go, Java and C#.
Retries
We treat any response below 300 as success. Anything else, a timeout, a connection error or a 3xx, 4xx or 5xx, counts as a failure. A failed delivery is attempted again after 30 seconds and a final time after 5 minutes, so you get three attempts in total. If all three fail the callback is marked failed and nothing more is sent, but the list and its results are untouched. You can always fall back to GET /lists/{id}.
Handling it well
- Respond fast. Return 2xx as soon as the signature checks out and push the real work to a queue. A slow handler looks like a failure and triggers a retry.
- Be idempotent. A retry can arrive after a handler that did succeed but answered slowly. Key your processing on
list.idso a second delivery does nothing. - Reject bad signatures with 401 and do not process the body.
- Use https. Callback URLs must be https, so your endpoint needs a valid certificate.
Questions
How do I verify the X-MX-Signature header?
Compute the HMAC-SHA256 of the raw request body using your account webhook secret, hex encode it, and compare it to the header with a constant-time comparison.
What happens if my server is down?
A failed delivery is retried after 30 seconds and again after 5 minutes. After the third attempt it is marked failed, and you can still read the results with GET /lists/{id}/results.
Does the webhook contain the results?
No. It contains a list summary with the id, status and counts. Fetch the rows from /lists/{id}/results.