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
- Log in to EmailSendCheck.com and go to Notifications and enter your URL under Webhook.
-
Then click on Save. We send a
testevent to the URL first, and only save it if your receiver answers with a 2xx status, such as 200. - Copy the Signing secret shown below the URL into your receiver.
- 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
| Event | When it's sent |
|---|---|
check.failing | A test failed. Sent after every failed test until the site passes again. |
check.unreachable | We couldn't reach the site to run its test. Sent after every test until it's fixed. |
check.recovered | The site passed again after failing or being unreachable. Sent once. |
channel.failing | Another of your alert channels (email or Slack) has stopped working. |
test | Sent 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:
| Header | Contains |
|---|---|
Content-Type | application/json |
X-EmailSendCheck-Event | The event name, such as check.failing. |
X-EmailSendCheck-Event-Id | The event ID, the same as id in the body. |
X-EmailSendCheck-Timestamp | When the request was signed, in Unix seconds. |
X-EmailSendCheck-Signature | sha256= and the request's signature. See Checking signatures. |
User-Agent | EmailSendCheck/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_atto 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.