Skip to content
MXora Email Send Check

Webhooks

Event types, what each request contains, and checking signatures.

Webhooks send each alert to your own system as a signed JSON request, so you can open tickets, post to other chat tools or update your own dashboards. They're included in the Agency plan.

Setting up

  1. Log in to EmailSendCheck.com and go to Notifications and enter your URL under Webhook.
  2. Then click on Save. We send a test event to the URL first, and only save it if your receiver answers with a 2xx status, such as 200.
  3. Copy the Signing secret shown below the URL into your receiver.
  4. Turn on webhook alerts for the sites you want, in each site's Send alerts to settings.

Send test event sends another test whenever you like (up to 20 an hour). New secret replaces the signing secret. The old one stops working straight away, so update your receiver at the same time.

Events

EventWhen it's sent
check.failingA test failed. Sent after every failed test until the site passes again.
check.unreachableWe couldn't reach the site to run its test. Sent after every test until it's fixed.
check.recoveredThe site passed again after failing or being unreachable. Sent once.
channel.failingAnother of your alert channels (email or Slack) has stopped working.
testSent when you save a new webhook URL, or click Send test event.

Webhooks always get every failure and recovery straight away, even for sites on the daily summary. Warnings are never sent as webhook events. They're listed in the dashboard and in your daily summary or weekly email instead.

The request

Each event is an HTTPS POST with a JSON body and these headers:

HeaderContains
Content-Typeapplication/json
X-EmailSendCheck-EventThe event name, such as check.failing.
X-EmailSendCheck-Event-IdThe event ID, the same as id in the body.
X-EmailSendCheck-TimestampWhen the request was signed, in Unix seconds.
X-EmailSendCheck-Signaturesha256= and the request's signature. See Checking signatures.
User-AgentEmailSendCheck/1.0 (+https://emailsendcheck.com/bot)

The body always has id, event, created_at (UTC, ISO 8601) and data. What's in data depends on the event. We may add new fields in future, but won't rename or remove existing ones, so ignore any fields you don't recognise.

check.failing

problems lists everything wrong with the test, worst first. Each verdict is fail or warning. headline is the worst problem's title. test.trigger is scheduled or manual.

{
    "id": "evt_01k93m7q2e4v8xg6t5r1c0hzpn",
    "event": "check.failing",
    "created_at": "2026-11-03T09:14:07Z",
    "data": {
        "check": {
            "id": 1842,
            "url": "https://example.com"
        },
        "test": {
            "id": "send_4f1c9a02b7d35e68a1c09f2e4b",
            "result": "fail",
            "tested_at": "2026-11-03T09:14:07Z",
            "trigger": "scheduled"
        },
        "headline": "DMARC fails with a reject policy",
        "message": "The scheduled email test for example.com failed.",
        "problems": [
            {
                "title": "DMARC fails with a reject policy",
                "detail": "example.com tells receivers to reject email that isn't authenticated, and this email wasn't. Gmail and Outlook will most likely reject this site's emails. Fixing SPF or DKIM (so they match the From domain) fixes this.",
                "verdict": "fail"
            },
            {
                "title": "Not signed with DKIM",
                "detail": "Receivers can't verify the email really came from this domain. Send through an email service that signs email for example.com, and add the DKIM records it gives you to DNS.",
                "verdict": "warning"
            }
        ],
        "connection_status": null,
        "fix_steps": [],
        "testing_stopped": false,
        "failing_since": "2026-11-03T09:14:07Z",
        "dashboard_url": "https://emailsendcheck.com/app/email-checks"
    }
}

check.unreachable

connection_status says why, using the statuses on Connection problems (the anchor on that page matches, with hyphens instead of underscores). fix_steps lists what to do. testing_stopped is true when the site has been disconnected, and we've stopped testing it until it's connected again.

{
    "id": "evt_01k93q1d8b6n3wz5y7m2k4atjr",
    "event": "check.unreachable",
    "created_at": "2026-11-03T13:14:02Z",
    "data": {
        "check": {
            "id": 1842,
            "url": "https://example.com"
        },
        "test": {
            "id": "send_9b27e0d4c61f8a35e2d70b1c9f",
            "result": "not_run",
            "tested_at": "2026-11-03T13:14:02Z",
            "trigger": "scheduled"
        },
        "headline": "Blocked by a firewall: allowlist our IPs / User-Agent",
        "message": "We couldn't reach example.com to run its scheduled email test, so we can't tell whether its emails are working.",
        "problems": [],
        "connection_status": "firewall_blocked",
        "fix_steps": [
            "A firewall (such as Cloudflare, Sucuri or the host's own) blocked our request.",
            "Allowlist our IP address (51.255.235.184) and the \"EmailSendCheck\" User-Agent.",
            "Then click Retry connection."
        ],
        "testing_stopped": false,
        "failing_since": "2026-11-03T09:14:07Z",
        "dashboard_url": "https://emailsendcheck.com/app/email-checks"
    }
}

check.recovered

problems lists any warnings when the site passed with warnings (test.result is warning).

{
    "id": "evt_01k93ttg5c2r9pv1e8x6h3dmqs",
    "event": "check.recovered",
    "created_at": "2026-11-03T15:02:44Z",
    "data": {
        "check": {
            "id": 1842,
            "url": "https://example.com"
        },
        "test": {
            "id": "send_c03e8f1a9d5b27e64f0a8c1d3b",
            "result": "pass",
            "tested_at": "2026-11-03T15:02:44Z",
            "trigger": "manual"
        },
        "headline": "The latest test passed",
        "message": "The manual email test for example.com passed. It had been failing since Tue 3 Nov, 09:14 GMT (5 hours 48 minutes).",
        "problems": [],
        "connection_status": null,
        "fix_steps": [],
        "testing_stopped": false,
        "failing_since": "2026-11-03T09:14:07Z",
        "dashboard_url": "https://emailsendcheck.com/app/email-checks"
    }
}

channel.failing

channel is email or slack. missed_alert is the subject of the alert that didn't get through, or null when we don't know which one it was (such as a bounced email).

{
    "id": "evt_01k93v4hz0q7m2c8e5t1b6wnra",
    "event": "channel.failing",
    "created_at": "2026-11-03T15:20:00Z",
    "data": {
        "channel": "slack",
        "destination": "#client-sites in the Acme Agency workspace",
        "message": "Alerts aren't reaching Slack.",
        "reason": "Slack says this webhook no longer works. The Slack app may have been removed, or the channel deleted or archived.",
        "fix": "Connect Slack again, or paste a new webhook URL.",
        "failing_since": "2026-11-03T15:10:31Z",
        "missed_alert": "Email failing: example.com",
        "channels_url": "https://emailsendcheck.com/app/notifications/channels"
    }
}

test

{
    "id": "evt_01k93w0yb3f6k9s2d4n7q1xvhe",
    "event": "test",
    "created_at": "2026-11-03T15:20:00Z",
    "data": {
        "message": "This is a test event from Email Send Check. Alerts for your email send checks will be sent to this URL."
    }
}

Checking signatures

Check every request's signature, so you know it came from us and wasn't changed. The signature is an HMAC-SHA256, using your signing secret as the key, of the timestamp header, a full stop, and the raw request body:

X-EmailSendCheck-Signature: sha256=hex( HMAC-SHA256( secret, timestamp + "." + raw_body ) )
  • Use the raw body exactly as received. Parsing the JSON and encoding it again changes it, and the signature won't match.
  • Compare signatures with a constant-time function, as below.
  • Reject requests whose timestamp is more than 5 minutes old, so a captured request can't be sent again later.

PHP

$secret    = getenv('EMAILSENDCHECK_WEBHOOK_SECRET');
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_EMAILSENDCHECK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_EMAILSENDCHECK_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// Handle $event['event'] here, then reply 200.

Node.js (Express)

const crypto = require('crypto');

app.post('/webhooks/emailsendcheck', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.get('X-EmailSendCheck-Timestamp') || '';
    const signature = req.get('X-EmailSendCheck-Signature') || '';
    const expected = 'sha256=' + crypto
        .createHmac('sha256', process.env.EMAILSENDCHECK_WEBHOOK_SECRET)
        .update(timestamp + '.' + req.body)
        .digest('hex');

    const valid = signature.length === expected.length
        && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

    if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(401);
    }

    const event = JSON.parse(req.body);
    // Handle event.event here.
    res.sendStatus(200);
});

Responding, retries and duplicates

  • Answer with a 2xx status within 10 seconds. Anything else counts as a failed delivery. If you have slow work to do, queue it and reply first.
  • Redirects aren't followed. Use the final URL.
  • Failed deliveries are retried after about a minute, then about ten minutes. If the last try fails, we tell you by email (and in Slack, if you use it) and show the problem on your Dashboard and the Notifications page.
  • A retried event keeps the same id, so store the IDs you've handled and ignore repeats.
  • Every event is delivered, even if a newer test has happened since, so events can arrive out of order, for example when one is retried. Use test.tested_at to tell which result is newest.

Which URLs are allowed

For security, the webhook URL must use https:// with a valid certificate, use a domain name rather than an IP address, and use port 443 or 8443. It must point to a public address: we check where the domain points before every delivery, and won't send to private or internal networks. If your receiver is behind a firewall, allowlist our IP address.

Still stuck?

Browse all the docs, read the FAQ, or get in touch and we'll help.

Get in touch