Bytloop MailDocs
Status

Getting started

  • Introduction
  • Quickstart

Sending email

  • Send a transactional email
  • Sending domains vs mailboxes

Framework guides

  • Send email from Next.js
  • Send email from Ruby on Rails
  • Send email from Django
  • Send email from Laravel

Domains

  • Verify a domain

Receiving email

  • Receive email
  • Webhooks

Reference

  • Rate limits
  • Errors
  • SDKs

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#

EventWhen it fires
email.deliveredThe receiving mail server accepted the message.
email.bouncedA hard or soft bounce; bounce_type distinguishes.
email.complainedThe recipient hit "Report spam" — recipient is auto-suppressed.
email.openedThe tracking pixel loaded (best-effort; not every client loads images).
email.clickedThe recipient clicked a tracked link.
email.delivery_delayedTemporary failure; Bytloop will keep retrying.
email.suppressedA send was blocked because the recipient is on the suppression list.
email.receivedA 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"
  }
}
{
  "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);
}
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);
  },
);
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);
  },
);

Return quickly

Bytloop treats anything slower than 15s as a timeout and retries. Do the actual work in a queue; the webhook handler should only enqueue and return 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.
PreviousReceive emailNextRate limits

On this page

  • Registering an endpoint
  • Event types
  • Payload shape
  • Verifying the signature
  • Retries
  • Next