Home / Developers / Webhooks and callbacks

Webhooks and callbacks

Get a signed call when a list finishes instead of polling, and check the signature in Python, Node.js or PHP.

Updated 2026-10-18

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, timingSafeEqual or hash_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.id so 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.

Related