Receiving email
Webhooks
Receive signed delivery, engagement, and inbound events at your own endpoint.
Webhooks are how Bytloop tells your app about things that happen after a send — a bounce, a delivery, an open, a click, or a new inbound message. Every event is HTTPS POSTed to endpoints you register in Settings → Webhooks, retried with exponential backoff on failure, and signed with an HMAC secret unique to the endpoint.
Registering an endpoint#
In the dashboard, add the HTTPS URL that will receive events and pick
which event types it should get. Bytloop returns a secret starting with
whsec_... — store it, you will need it to verify signatures. The
secret is only shown once.
An endpoint that fails 100 consecutive deliveries is disabled automatically and you are notified by email. Re-enable it once the underlying problem is fixed and Bytloop will resume delivery from the oldest un-delivered event.
Event types#
| Event | When it fires |
|---|---|
email.delivered | The receiving mail server accepted the message. |
email.bounced | A hard or soft bounce; bounce_type distinguishes. |
email.complained | The recipient hit "Report spam" — recipient is auto-suppressed. |
email.opened | The tracking pixel loaded (best-effort; not every client loads images). |
email.clicked | The recipient clicked a tracked link. |
email.delivery_delayed | Temporary failure; Bytloop will keep retrying. |
email.suppressed | A send was blocked because the recipient is on the suppression list. |
email.received | A new inbound message arrived at a mailbox on a verified domain. |
Payload shape#
Every payload wraps a discriminated union so you can switch on type:
{
"id": "evt_2N7hJqLxYc8bR3fT",
"type": "email.delivered",
"created_at": "2026-04-14T20:31:16.412Z",
"data": {
"email_id": "em_2N5kQpZxV9wKqL3jH7yT",
"to": ["customer@example.com"],
"tags": { "receipt_for_order": "4821" },
"delivered_at": "2026-04-14T20:31:16.401Z",
"provider_message_id": "010f0192a3b4c5d6-abcd-4444-8888-cccccccccccc-000000@eu-west-1.amazonses.com"
}
}The email_id matches the id returned by the send call, so you can
correlate an event back to the original request in your own database.
Verifying the signature#
Every webhook request carries two headers:
Bytloop-Signature— hex-encoded HMAC-SHA256 of the request body, keyed by your endpoint secret.Bytloop-Timestamp— ISO-8601 timestamp of when the signature was computed. Reject requests older than five minutes to defeat replay.
The signature payload is ${timestamp}.${rawBody} — sign the exact
bytes you received, not the parsed JSON, or trailing whitespace will
break verification.
import crypto from "node:crypto";
export function verifyBytloopWebhook(
rawBody,
signatureHeader,
timestampHeader,
secret,
) {
const fiveMinutes = 5 * 60 * 1000;
const timestampMs = Date.parse(timestampHeader);
if (!Number.isFinite(timestampMs)) return false;
if (Math.abs(Date.now() - timestampMs) > fiveMinutes) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
// Constant-time compare so an attacker can't measure how many
// leading bytes matched.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureHeader, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Wire it into your Node handler by reading the raw body — most frameworks parse it into JSON by default, which destroys the exact byte sequence:
app.post(
"/webhooks/bytloop",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyBytloopWebhook(
req.body.toString("utf8"),
req.header("Bytloop-Signature"),
req.header("Bytloop-Timestamp"),
process.env.BYTLOOP_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type
res.sendStatus(204);
},
);Retries#
A non-2xx response — or no response within 15s — is retried on an
exponential schedule of roughly 1m, 5m, 25m, 2h, 8h, 24h (up to 6
attempts over 24 hours). Every attempt reuses the same event id, so
your handler must be idempotent: dedupe on that id before writing.
Next#
- The end-to-end shape is easier to explore visually — head to Settings → Webhooks and use the Send test event button to fire a synthetic delivery event at your endpoint.