Webhooks you receive
Docs / Webhooks

Webhooks you receive

When a message changes state, Spoolway POSTs that event to an HTTPS endpoint you register. These are the events we send to you. Provider feeds, the other page in this group, are the callbacks a provider sends to us.

Events

An endpoint subscribes to any of these. The type on the delivery is one of them.

  • message.queued
  • message.handed
  • message.delivered
  • message.bounced
  • message.complained
  • message.failed
  • message.deferred
  • message.suppressed

message.delivered is sent only when a provider reported delivery on a transport that can report it. Plain SMTP never emits it, Amazon SES over SMTP included, and that mail stops at message.handed.

The body is a JSON object with id, type, created_at and data. data carries the message id, the account, the application, the status, the evidence, from, the subject (null when the subject is redacted), the recipients and the transport. It does not include the HTML or text body, the template variables, or any credential. The bytes you verify are the raw body, encoded with slashes left as slashes. Do not parse it and encode it again before checking the signature.

Verifying a delivery

Every delivery carries Spoolway-Id, Spoolway-Timestamp and Spoolway-Signature, plus Content-Type: application/json and User-Agent: Spoolway-Webhooks/1.0. The id stays the same when a delivery is retried. The timestamp and the signature are new on each attempt.

The signature header is t=the timestamp, then a comma, then v1=the lowercase hex HMAC. The signed string is the timestamp, a full stop, and the raw body, as UTF-8. v1 is HMAC-SHA256 of that string with the endpoint secret. Compare it in constant time. Reject the delivery when the absolute difference between now and the timestamp is greater than 300 seconds, which is the replay window this ships with. The secret is shown once, when the endpoint is created, and it starts with spw_wh_.

Both samples are a complete check: they read the header, refuse a timestamp outside 300 seconds and compare in constant time. The worked example below them is a real signature from 2023, so its call passes that moment as now; a check against the current time would reject it. Its body is not a full delivery, only the exact string the signature is computed over.

PHP
// $rawBody: the request body exactly as received — not re-encoded JSON.
function spoolwayVerified(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
        $parts[$key] = $value;
    }
    $timestamp = (int) ($parts['t'] ?? 0);
    if ($timestamp === 0 || abs(($now ?? time()) - $timestamp) > 300) {
        return false; // outside the 300-second window
    }
    $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $parts['v1'] ?? '');
}

// Worked example: the header Spoolway sends for this body and secret.
$ok = spoolwayVerified(
    '{"id":"dlv_example","type":"message.handed"}',
    't=1700000000,v1=f7cd6b0d7b762625a8faea4b7d86233d57a18b6067dba75c2d9bcc153212226b',
    'spw_wh_example',
    1700000000,
); // true
Node
const crypto = require("crypto");

// rawBody: the request body exactly as received — not re-encoded JSON.
function spoolwayVerified(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
    const parts = Object.fromEntries(header.split(",").map((pair) => pair.trim().split("=", 2)));
    const timestamp = Number(parts.t);
    if (!timestamp || Math.abs(now - timestamp) > 300) {
        return false; // outside the 300-second window
    }
    const expected = crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
    const given = String(parts.v1 || "");
    return given.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

// Worked example: the header Spoolway sends for this body and secret.
const ok = spoolwayVerified(
    '{"id":"dlv_example","type":"message.handed"}',
    "t=1700000000,v1=f7cd6b0d7b762625a8faea4b7d86233d57a18b6067dba75c2d9bcc153212226b",
    "spw_wh_example",
    1700000000,
); // true

Retries

A delivery that does not get a 2xx is tried again. By default there are 8 attempts. The waits between them are 30, 60, 120, 300, 900, 1,800 and 3,600 seconds. A 2xx marks that delivery succeeded. Redirects are not followed, so a 3xx counts as a failure. The request times out after 10 seconds by default. Deduplicate on Spoolway-Id: a retry reuses it.

Answering 410 Gone disables the endpoint immediately. So does a URL that resolves to a private or reserved address, or that no longer resolves. A test from the panel, and a replay, are a single try. They are not retried and they do not count toward the threshold below, except that a 410 or a refused URL still disables the endpoint.

After 5 consecutive deliveries each exhaust all 8 attempts without a 2xx, the endpoint is disabled. At those defaults the reason is: Auto-disabled after 5 consecutive deliveries exhausted all 8 attempts without a 2xx response.