Webhooks
Webhooks let your server hear about things as they happen: an email delivered, bounced, opened or clicked, a contact unsubscribing, a domain getting verified, and more. We send each event as a signed HTTP POST to a URL you choose. Paths are relative to https://app.blacklistguard.com/api/v1.
Setting up an endpoint
The quickest way is in the console, under Webhooks. Add your URL, pick the events you want, and copy the signing secret you're shown. You can also do it through the API (see Managing endpoints).
- Nothing is sent until you choose some events. Pick event types one by one, or choose All events, which also includes any types we add later.
- The URL must be https and reachable on the public internet. If your server needs basic auth, put it in the URL, like
https://user:pass@example.com/hooks. - You can add up to 5 custom headers, for example an API token your server expects.
- The Free plan includes one endpoint. Other plans can have as many as they need.
Event types
| Group | Events |
|---|---|
email.accepted, email.scheduled, email.delivered, email.deferred, email.bounced, email.dropped, email.failed, email.expired, email.held, email.rendering_failed, email.opened, email.clicked, email.unsubscribed, email.complained, email.received | |
| Contacts | contact.created, contact.updated, contact.subscription_changed, suppression.added, suppression.removed |
| Domains | domain.created, domain.deleted, domain.verified, domain.failed |
| Account | account.sending_paused, account.sending_reinstated, account.usage_warning, account.usage_reached, account.daily_limit_reached |
| Campaigns | campaign.sent, campaign.ab_test_completed |
| Testing | webhook.test |
GET /webhooks/event-types returns every type with a short description and an example of its data, so you can see exactly what each one looks like before you build for it.
What your server receives
Each event is a POST with Content-Type: application/json and this body:
{
"id": "…",
"type": "email.delivered",
"timestamp": "2026-11-09T10:15:00Z",
"data": { … }
}
Events about a message you sent through the API or SMTP include the tags and metadata you gave it, so you can match the event to your own records.
Three headers come with every request:
| Header | What it holds |
|---|---|
webhook-id | The event's ID. It stays the same when we retry or you replay the event, so use it to ignore duplicates. |
webhook-timestamp | When the request was signed, in Unix seconds. |
webhook-signature | The signature, in the form v1,<signature>. |
Answer with any 2xx status to say you've got it. Reply quickly and do any slow work afterwards.
Verifying the signature
Check the signature on every request, so you know it really came from us and wasn't changed on the way. Webhooks follow the Standard Webhooks format, so their libraries can verify them for you. To do it yourself:
- Take your secret, drop the
whsec_at the start, and base64-decode the rest. That's your key. - Join the
webhook-id, thewebhook-timestampand the raw request body with dots:id.timestamp.body. - Work out the HMAC-SHA256 of that with your key, and base64-encode it.
- Compare it with the part of
webhook-signatureafterv1,. If the header holds more than one signature, separated by spaces, accept the request when any of them matches.
For example, in Node.js:
import crypto from "node:crypto";
function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
// Refuse anything signed more than 5 minutes ago, so an old request can't be replayed.
if (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("base64");
return headers["webhook-signature"].split(" ").some((entry) => {
const [version, signature] = entry.split(",");
return version === "v1" && signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
}
Use the body exactly as it arrived. If your framework parses the JSON first and you re-encode it, the signature won't match.
When you rotate the secret, the old one keeps working alongside the new one for 24 hours, which gives you time to update your server. During that time each request carries two signatures, the new secret's first and then the old one's, like v1,<new> v1,<old>. The code above accepts either.
Retries and failures
If your server doesn't answer with a 2xx, we try again 5, 10 and 15 minutes later, then after 1, 2 and 4 hours. That's about 8 hours in all, after which the delivery is marked failed. Redirects aren't followed, so a 3xx counts as a failed attempt and is retried too. Two answers are handled differently:
410 Goneturns the endpoint off straight away.406 Not Acceptabledrops that one event and nothing else.
If an endpoint keeps failing, your workspace admins get an email. After 48 hours with nothing delivered, it's turned off and they get another one. When it starts working again, they're told that too. To turn an endpoint back on, open it under Webhooks in the console and choose Turn on.
Every delivery and each of its attempts is kept for 10 days. In the console you can see the exact body we sent, what your server answered, and replay any delivery.
Managing endpoints
GET /webhooks/event-types
GET /webhooks
POST /webhooks
GET /webhooks/{id}
PUT /webhooks/{id}
DELETE /webhooks/{id}
POST /webhooks/{id}/rotate-secret
POST /webhooks/{id}/test
GET /webhooks/{id}/deliveries?status=&type=&page=&limit=
GET /webhooks/{id}/deliveries/{delivery_id}
POST /webhooks/{id}/deliveries/{delivery_id}/replay
Create takes { url, description, all_events, event_types, headers }, where each header is { name, value }. It returns the endpoint plus its secret, which you won't see again. An endpoint looks like this:
{
"id": "…",
"url": "https://example.com/hooks",
"description": "Order system",
"all_events": false,
"event_types": ["email.delivered", "email.bounced"],
"headers": [{ "name": "X-Api-Token" }],
"status": "active",
"disabled_reason": null,
"failing_since": null,
"last_success_at": "2026-11-09T10:15:02Z",
"last_failure_at": null,
"created_at": "…",
"updated_at": "…"
}
Header values and passwords are never shown again once saved. A password in the URL reads back as ****.
Update replaces the whole endpoint, so send every field: url, description, all_events, event_types, headers and status. To keep the stored password, send the URL back exactly as you read it. To keep a header's value, send it as { name } with no value. Set status to disabled to turn the endpoint off, or active to turn it back on. When it's off, disabled_reason says why: manual (you turned it off), failing (nothing delivered for 48 hours) or gone (your server answered 410).
Rotate secret returns a new secret. Test sends a webhook.test event straight away and tells you what happened: { delivered, http_status, response, error, duration_ms }. It never changes the endpoint.
Deliveries come 25 to a page by default (up to 100). Filter by status (pending, delivering, delivered, failed or dropped) or by event type. A single delivery includes the event exactly as sent and every attempt with its status code and response. Replay sends the same event again with the same ID. It doesn't work while the endpoint is turned off.
With an API key, these routes need the webhooks permission. A value that isn't valid returns 400 with a message naming the field, for example url must be an https address. Adding a second endpoint on the Free plan returns 402 with PLAN_LIMIT_REACHED.