Skip to content

Webhooks from Spaw: signed callbacks, and how to test one

updated · October 8, 2026

The HTTPS callbacks Spaw sends, how to verify the timestamped X-Spaw-Signature-V2 HMAC in Node or PHP, how to test a receiver, and how retries work.

Mechanism
Outbound HTTPS POST from Spaw to your URL
Works today
Yes
Auth
X-Spaw-Signature-V2, HMAC-SHA256 of the timestamp and the raw body
Retries
Two, on connection errors and 5xx answers (three attempts)
Cost
None beyond the lookups themselves

Spaw calls you back in three situations: when a queued bulk run finishes, when a monitored list has been re-checked, and when a single lookup that first answered unverified settles later. All of them are plain HTTPS POSTs with a JSON body and a signature header, so they work with a few lines of code on your server or with the incoming-webhook trigger of any automation tool. You can send yourself any of them on demand, before a single credit is spent — see testing a receiver below.

The bulk completion webhook

  1. Create the run with POST https://spaw.co/api/v1/email/bulk, passing emails (up to 100,000) and a webhook_url that speaks HTTPS.
  2. Store the webhook_secret from the response. It is returned at creation, and again only if you replay the same request with the same Idempotency-Key header; otherwise losing it means creating a new run to get a new one.
  3. When the job reaches completed, failed or cancelled, Spaw POSTs to your URL:
{
  "event": "bulk_email_job.finished",
  "job": {
    "id": 512,
    "status": "completed",
    "total": 2,
    "processed": 2,
    "deliverable": 1,
    "risky": 0,
    "undeliverable": 1,
    "credits_used": 1,
    "stopped_reason": null,
    "finished_at": "2026-09-03T10:14:02+00:00"
  }
}
  1. Answer with any 2xx status quickly and do the real work afterwards. A connection error or a 5xx answer is retried twice with a short backoff — three attempts in all — and the job's webhook_status field then reads delivered or failed.
  2. Fetch the CSV from the results endpoint, optionally filtered with ?variant=deliverable, risky or undeliverable. Results are kept for 30 days.

Asking for a completion webhook again

A delivery gets three attempts over about half a minute, and then it is gone. If your endpoint was down for those thirty seconds, the run is finished and billed and your pipeline never hears about it — and the only way left to find out is to poll, which is the thing this webhook exists to avoid. So you can ask for it again: press Send webhook again on the run in the dashboard, or call POST https://spaw.co/api/v1/email/bulk/{jobId}/webhook/redeliver (reference). Every bulk product has the same call — phone, IP, address and cross-signal runs each answer it under their own path.

Four things are worth knowing before you rely on it.

The body is the same body. It is rebuilt from the run's own row rather than replayed from a stored blob, and every field the payload carries is frozen once a run has settled, so the bytes are the ones the first delivery carried. If you kept the original, the two reconcile exactly. (A run you resume later settles again and posts a completion webhook of its own; a redelivery always repeats the most recent one.)

The signature is not the same signature, and that is correct. Each delivery is signed as it is sent, so X-Spaw-Signature-V2 carries a later t and a different v2 digest. That is required rather than incidental: your receiver refuses anything whose t is more than five minutes from its own clock — see why the timestamp — so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery, with the same code. The older X-Spaw-Signature is the HMAC of the body alone, so it is byte-identical to the first attempt's, until it stops being sent on 1 March 2027.

Nothing deduplicates it for you. Each call is one real POST, so if the original did arrive after all, your receiver sees the event twice. Both bodies carry job.id: treat bulk_email_job.finished for a job id you have already handled as a repeat and drop it. That is the same defence you want against your own retries, so it is worth having either way.

The delivery is queued, and the answer is 202. One attempt is a ten-second timeout retried twice — about half a minute against a receiver that is down, which is the receiver you are asking about — so the call does not wait for it. It answers 202 with the run at once, and webhook_status on that run reads pending: asked for, not answered yet. Poll the job until it reads delivered or failed; webhook_detail then carries the HTTP status your endpoint gave, or the transport failure when it gave none. The dashboard shows the same three states beside the run and refreshes them on its own. A run sitting at pending has a delivery in flight — asking again queues a second one, so wait for the first before deciding it failed.

A run created without a webhook_url answers 409 WEBHOOK_NOT_CONFIGURED, which is checked first because waiting will never fix it; one still queued or running answers 409 JOB_NOT_FINISHED. A run whose last delivery succeeded is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did. It costs nothing, and it is capped at ten a minute counted per signed-in user: neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button spends the same budget its owner's keys do.

The monitor run webhook

A monitored list is re-checked on a schedule, so its webhook says when it posts as well as what: webhook_events is changes (the default — only a run that found decay, a status change, a new flag, or that stopped early) or every_run, a heartbeat that also tells you the monitor is still running. Set webhook_url when you create the monitor, or with a PATCH afterwards; the webhook_secret comes back once from whichever call set the URL. The body is { "event": "email_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": …, "changes": [ … ] }, where summary is the same block the monitor reports as last_summary and changes names the entries the alert email names. A phone monitor's body adds flags, mapping each newly flagged number to the change flags it raised ({} when none did). Each product posts under its own event name, and the monitor itself carries webhook_status and webhook_detail afterwards, so a delivery that failed can be read back without waiting for the next run.

The settled-verdict callback

Some mail servers will not say whether a mailbox exists on the first try (greylisting, timeouts). Pass callback_url and a callback_secret of 16 to 128 characters on POST /api/v1/email, and an unverified answer is re-checked after 5 and 20 minutes. The response to your original request then carries meta.settling: true, and the callback body is:

{
  "event": "email.settled",
  "request_id": "req_01m1kgdm4xngzmbmff68g94w0c",
  "settled": true,
  "attempt": 1,
  "data": { "email": "[email protected]", "deliverable": "deliverable", "smtp_checked": true },
  "meta": { "credits_used": 1, "credits_remaining": 41, "cache_hit": false, "fair_use_billed": false }
}

data is the full 46-field result. settled is false when the last re-check still could not say. An unverified answer is free and leaves nothing to repeat, so each re-check is billed like a fresh lookup: 1 credit when it confirms the mailbox, nothing while it still cannot say. A re-check your balance cannot pay for is not run: the last callback then has settled false, data null, meta.credits_used 0 and meta.stopped_reason insufficient_credits.

Verifying the signature

Every delivery carries X-Spaw-Signature-V2, two comma-separated elements:

X-Spaw-Signature-V2: t=1757502000,v2=8d4f…c1

t is when we signed the payload, in whole seconds since the Unix epoch. v2 is the lowercase hex HMAC-SHA256, keyed with the secret you were given, of this exact string:

v2:{t}:{the raw request body}

So the signature covers the timestamp as well as the bytes, and neither can be changed without invalidating it. Compute your side over the bytes as received, before any JSON parsing — a body re-encoded from parsed JSON is a different string and will not match — and compare in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function isFromSpaw(rawBody, header, secret) {
  const elements = new Map();
  for (const part of String(header ?? '').split(',')) {
    const at = part.indexOf('=');
    if (at > 0) {
      elements.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
    }
  }

  const timestamp = elements.get('t') ?? '';
  const signature = elements.get('v2') ?? '';

  if (!/^\d+$/.test(timestamp) || signature === '') {
    return false;
  }

  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = createHmac('sha256', secret).update(`v2:${timestamp}:`).update(rawBody).digest('hex');

  return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
$elements = [];

foreach (explode(',', (string) $request->header('X-Spaw-Signature-V2')) as $element) {
    $pair = explode('=', trim($element), 2);

    if (count($pair) === 2) {
        $elements[$pair[0]] = $pair[1];
    }
}

$timestamp = $elements['t'] ?? '';

abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300, 401);

$expected = hash_hmac('sha256', "v2:{$timestamp}:".$request->getContent(), $secret);

abort_unless(hash_equals($expected, $elements['v2'] ?? ''), 401);

All three clients ship this as one call, so you do not have to write it at all: spaw.verify_webhook(body, header, secret) in Python, verifyWebhook(body, header, secret) in Node, Spaw\Webhook::verify($body, $header, $secret) in PHP. Each returns a plain true or false and does the constant-time comparison for you. The tolerance is the one thing they do not spell the same way, so take it from your own client rather than from a sentence about all three: Python's is the keyword tolerance=, PHP's the fourth argument int $tolerance, and Node's a key on an options object — verifyWebhook(body, header, secret, { toleranceSeconds: 600 }). A bare number in Node's fourth position throws, rather than being read as an options object with no toleranceSeconds in it and leaving the window quietly at five minutes.

Why the timestamp, and what to do about clocks

A signature over the body alone proves the bytes came from us and were not modified. It says nothing about when, so it never goes stale: anyone who gets hold of one delivery — from a log aggregator, a proxy, an error report pasted into a ticket — can post those exact bytes at your endpoint next year and your check will pass, because it is the same body and the same secret. Signing the timestamp is what fixes that, and it only works if you actually enforce it: reject anything whose t is more than five minutes from your own clock, in either direction. Too old is the replay. Too far in the future is a signature that would stay valid for as long as your clock is wrong, so a machine whose time has run away should fail loudly rather than accept everything.

Five minutes is the window we design for. We stamp the payload when we sign it, and the delivery, including both retries, is finished about half a minute later, so the rest of the window is slack for your clock. Keep it synced with NTP; if you genuinely cannot, widen the window rather than dropping the check, and never make it unbounded.

Five minutes narrows a replay rather than closing it. To close it, remember what you have already accepted for the length of the window — the signature itself is a fine key, or job.id plus event, or request_id — and ignore a second delivery carrying one you have seen. That is also the answer to a genuine retry arriving twice because your 2xx was lost on the way back to us.

The older header, and until when

Deliveries also carry the original X-Spaw-Signature: the same HMAC-SHA256, keyed with the same secret, but over the raw body alone with no timestamp. It is unchanged, it still verifies, and receivers written against it keep working. It is also the header with the replay problem, so it is going away: both headers are sent until 1 March 2027, and after that only X-Spaw-Signature-V2. Nothing about your secret, your URL or the payloads changes when it goes — move your check over to the timestamped header any time before then, and remember to enforce the window when you do, because that is the whole point of moving.

Testing a receiver before anything depends on it

The code above used to be written blind: the first signed request you ever saw arrived when a bulk run you had queued and paid for happened to finish. POST /api/v1/webhooks/test sends you a real signed delivery of any payload on demand instead. It is free, and it takes two fields:

curl https://spaw.co/api/v1/webhooks/test \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/spaw","event":"bulk_email_job.finished"}'

The answer is everything about the delivery that just went out:

{
  "success": true,
  "data": {
    "delivered": true,
    "status_code": 200,
    "detail": "HTTP 200",
    "secret": "3nQ8Zk2xVb7Lp1sTfY6wR4mJcH0aD5gU9eKqNvXz",
    "signature": "45f6705121de705be218247b0f31a49d895c46d2e9f9d38d150bec2f08b629b9",
    "timestamped_signature": "t=1757502000,v2=6a0e5f0f6bd0ba9a4bd6c9bbd0c0b5b6b1a1d0f04a2b9a02de3b1a3d0c4b6f21",
    "timestamp": 1757502000,
    "body": "{\"test\":true,\"event\":\"bulk_email_job.finished\",\"job\":{\"id\":512,…}}"
  }
}

body is the exact bytes that were signed and posted, and the two signatures are the two headers that went with them, so a check that fails can be debugged with no receiver at all. timestamped_signature is the whole X-Spaw-Signature-V2 value the delivery carried: hash v2:{timestamp}:{body} with secret and you should arrive at everything after v2=. timestamp is that same t, repeated so you need not take the header apart to get at it — in a real receiver you read it out of the header you were sent, because that is the value a replayed delivery would carry. signature is the older X-Spaw-Signature over body alone, which is what a check written against that header has to arrive at; it stops being sent on 1 March 2027, so a check written today belongs on the timestamped pair. The secret is minted for that one call and stored nowhere — a real job's or monitor's signing secret is shown once at creation and never handed out again, and this endpoint will not hand you one.

event is any of the names Spaw posts:

Event Sent when
bulk_email_job.finished A bulk email run reaches completed, failed or cancelled
bulk_phone_job.finished The same, for a bulk phone run
bulk_ip_job.finished The same, for a bulk IP run
bulk_address_job.finished The same, for a bulk address run
bulk_consistency_job.finished The same, for a bulk cross-signal consistency run
email.settled A lookup that first answered unverified has settled
email_monitor.run A monitored email list has been re-checked
phone_monitor.run A monitored phone list has been re-checked
ip_monitor.run A monitored IP list has been re-checked
address_monitor.run A monitored address list has been re-checked
entity_monitor.run A monitored identifier list has been re-checked

One field differs from a real delivery: a test body carries "test": true, first. It is there because most low-code webhook triggers cannot compute an HMAC over a raw body, and a receiver that cannot check a signature still has to be able to tell a test from the real thing. Everything else — both signature headers, the timeout, the retries, the shape of the body — is the same code that posts a real one.

The limits are worth knowing, because the endpoint makes an outbound request on your say-so. The URL must be HTTPS. A host that resolves onto a private or reserved address is refused with nothing sent at all: delivered false and detail Refused: private address, which is also what a real delivery does and why a webhook pointed at your own network never arrives. A redirect is not followed, so a 3xx answer is reported as a failure rather than chased. The body is ours and fixed — nothing you send is copied into it. Whatever your endpoint writes in its own response body is never read or returned; only its status code comes back. And the endpoint is capped at ten requests a minute for your key, which is enough to debug a receiver and not enough to make Spaw a source of traffic against somebody.

Receiving webhooks in an automation tool

Zapier's catch-hook trigger, Make's custom webhook and n8n's Webhook node all accept these POSTs and expose the body's fields to the following steps. Most low-code tools cannot compute an HMAC on the raw body, so keep the receiving URL private and check event plus the job id against the run you created. They cannot enforce the timestamp either, which is worth knowing for what it means: a delivery captured from such a flow stays useful to whoever captured it, so treat the URL as the credential it has become. For a typical flow, the webhook trigger is followed by an HTTP step that downloads the results CSV and a step that writes rows into a sheet or CRM; the n8n page shows the HTTP step.

What to do with the verdict

The completion payload is a summary. Decisions belong to the per-row verdicts in the CSV: deliverable rows go to your mailing tool, risky rows are worth a look at their reason, and undeliverable rows should be removed, or corrected when a did_you_mean column is filled. Undeliverable rows are also added to your account's suppression list automatically, so future batch and bulk runs answer them for free.

Cost

Webhooks and callbacks themselves are free. The bulk run bills each row like a single lookup as it is processed: 1 credit for a fresh deliverable or risky verdict, nothing for undeliverable rows, duplicates, seven-day repeats or suppressed addresses.

Related

markdown version: /integrations/webhooks.md

Verify addresses the same way

The Spaw API runs every check described here on each lookup, with an SMTP handshake that never sends mail. 10 free lookups a month, no card required.

Get your API key

More integrations