Guides
Webhooks
Receive real-time HTTP callbacks when events occur in your Kawaa account, such as batch job completion or domain monitoring alerts.
How webhooks work
- You configure a webhook URL in your Dashboard or via API
- An event occurs (e.g., batch job completes)
- Kawaa sends an HTTP POST request to your URL with event data
- Your server processes the event and responds with 2xx status
Webhook events
| Event | Description | Available |
|---|---|---|
job.completed | Batch verification job finished | Yes |
job.failed | Batch job failed to complete | Yes |
blacklist.detected | Domain detected on blacklist | Yes |
dmarc.failure | DMARC check failed for monitored domain | Yes |
verification.completed | Single email verification completed. A result reused from a recent check (from_cache: true) usually returns at once without this event, but not always, so do not rely on a cached result being silent | Yes |
verification.failed | Single email verification failed before producing a result | Yes |
credits.low | Credit balance fell below 20% of the plan allowance (at most once per 7 days) | Yes |
credits.exhausted | Credit balance dropped below 1 credit, the price of a fresh check (at most once per 24 hours) | Yes |
Single verifications emit verification.* events; bulk jobs emit job.* events. A job stopped with POST /v1/jobs/{job_id}/cancel emits neither job.completed nor job.failed. Query GET /v1/webhooks/events for the current catalog with per-event available flags; only available: true events can be subscribed to.
Webhook payload
{
"event": "job.completed",
"data": {
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "completed",
"total_emails": 1000,
"processed_emails": 1000,
"requested_emails": 1000,
"partial": false,
"credits_refunded": 0,
"summary": {
"valid": 850,
"invalid": 100,
"risky": 30,
"unknown": 20
},
"created_at": "2026-02-03T14:30:00.000Z",
"completed_at": "2026-02-03T14:35:42.000Z",
"download_url": "https://api.kawaa.com/v1/jobs/9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90/download"
},
"webhook_id": "wh_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"timestamp": "2026-02-03T14:35:42.100Z"
}total_emails and processed_emails count the addresses that were checked. requested_emails is how many you submitted and were charged for. When some addresses were never checked (the queue refused them, or the job stopped before reaching them), the job still completes with the results it has: partial is true, requested_emails is larger than total_emails, and the difference is addresses that have no result and were refunded. GET /v1/jobs/{job_id} and the job listing carry the same requested_emails and partial: true for such a job. credits_refunded is every credit returned for the job, including the discount for cached results, the same figure as on GET /v1/jobs/{job_id}. When that discount has not been applied yet, the event also carries cache_discount_pending: true, and credits_refunded will grow; read the job for the final figure. job.failed carries requested_emails and credits_refunded too.
A test delivery (the dashboard's Test button, or POST /v1/webhooks/{id}/test) has the same shape as a real delivery of its event, filled with placeholder values, and also carries "test": true.
Batch jobs submitted with a per-job webhook_url (on POST /v1/verify/batch or POST /v1/verify/file) receive a flat payload instead, when the job completes: {event, job_id, status, created_at, completed_at, results: {total, processed, valid, invalid, risky, unknown}, requested_emails, partial, credits_refunded, cache_discount_pending?, download_url}, and when it fails: {event: "job.failed", job_id, status, created_at, failed_at, error, results: {...}, requested_emails, credits_refunded}. Read results.total against requested_emails the same way. Those deliveries are signed only when you supply a webhook_secret with the batch or file request. The flat body has no timestamp of its own, so check the time in the signature header (below) to refuse a replayed one.
Signature verification
Each signed webhook request carries a signature you can check to prove it came from Kawaa, and a signed time you can check to refuse an old request replayed at you. Always validate both in production.
Headers
X-Kawaa-Event: job.completed
X-Kawaa-Signature-V2: t=1770129342,v1=def456...
X-Kawaa-Signature: sha256=abc123...
X-Kawaa-Timestamp: 2026-02-03T14:35:42.100Z
X-Kawaa-Id: wh_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6dX-Kawaa-Signature-V2 is the one to check. t is when the request was sent, in Unix seconds, and v1 is an HMAC-SHA256 of t, a period and the raw request body ("<t>.<raw body>"), computed with your webhook secret and hex-encoded. Because the time is signed, a request captured and sent again later fails your check once t is outside your tolerance window.
- Recompute the HMAC over the raw bytes you received (not a re-serialized parsed body) and compare it with a constant-time comparison, such as
crypto.timingSafeEqualin Node.js orhmac.compare_digestin Python. - Refuse the request when
tis more than 300 seconds from your clock, in either direction. That window is the recommended tolerance; keep your server's clock synchronized (NTP). - Every attempt is signed when it is sent, so an automatic retry, a manual retry from the delivery log and a test delivery each carry a fresh
t, and pass the window even when the delivery they repeat is older. Check the window againsttonly: the body'stimestampkeeps the time of the original delivery, andX-Kawaa-Timestampis informational and not signed. - A request replayed inside the window still verifies, so deduplicate as well (see Best practices).
X-Kawaa-Signature is the legacy signature, still sent unchanged so existing receivers keep working: an HMAC-SHA256 of the raw request body alone, hex-encoded after sha256=. It signs no time, so a captured request verifies against it forever. Move to X-Kawaa-Signature-V2, which uses the same secret.
X-Kawaa-Timestamp is an ISO-8601 timestamp (not a Unix epoch). X-Kawaa-Id is the id of the registered endpoint the delivery was made to. A per-job webhook (a webhook_url supplied on a batch or file request) has no endpoint id, so those deliveries carry no X-Kawaa-Id, and carry the two signature headers only when a webhook_secret was supplied with the request.
Verification (Node.js)
const crypto = require('crypto');
// Refuse a request signed more than this long ago, or this far ahead.
const TOLERANCE_SECONDS = 300;
// Only spaces and tabs around the header and its parts are ignored.
const trimSpaces = (value) => value.replace(/^[ \t]+|[ \t]+$/g, '');
// Checks X-Kawaa-Signature-V2: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
function verifyWebhookSignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
// No header (a forged request, or a per-job webhook sent without a
// webhook_secret) or no secret configured: not verified.
if (typeof header !== 'string' || !secret) return false;
let t;
const signatures = [];
for (const part of trimSpaces(header).split(',')) {
const item = trimSpaces(part);
const eq = item.indexOf('=');
if (eq === -1) return false;
const key = item.slice(0, eq);
const value = item.slice(eq + 1);
if (key === 't') {
if (t !== undefined || !/^[0-9]{1,15}$/.test(value)) return false;
t = value;
} else if (key === 'v1') {
if (!/^[0-9a-fA-F]{64}$/.test(value)) return false;
signatures.push(Buffer.from(value, 'hex'));
}
}
if (t === undefined || signatures.length === 0) return false;
// A replayed request is refused once it is older than the window.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.')
.update(rawBody)
.digest();
// Both sides are 32 bytes, so timingSafeEqual cannot throw on a length
// mismatch and turn a forged request into a 500 (retried) instead of a 401.
return signatures.some((signature) => crypto.timingSafeEqual(signature, expected));
}
// In your webhook handler, verify against the RAW request body;
// re-serializing a parsed body can change the bytes and break the HMAC.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyWebhookSignature(
req.body, // raw Buffer
req.headers['x-kawaa-signature-v2'],
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the webhook...
res.status(200).send('OK');
});A header may list more than one v1; the request is genuine when any of them matches. Entries with other names are ignored, so a later signature scheme can be added beside v1 without breaking this check.
Rotating the secret
When you regenerate a webhook's secret, the old secret stops working at once. Deliveries from then on are signed with the new secret only, and until your receiver has it they fail its check. Its refusal (a 401, or any other 4xx except 429) is final and is not retried, so those events are not delivered on their own. If you can, let your receiver accept a signature made with either the old or the new secret while you roll the new one out, then drop the old one. Afterwards, re-send any delivery that failed in the gap from the webhook's delivery log (Logs in the dashboard, or POST /v1/webhooks/{id}/logs/{logId}/retry); a re-send is signed with the current secret.
Retry policy
Job, verification and credit events (job.*, verification.*, credits.*), and monitor alerts (blacklist.detected, dmarc.*) from a scheduled monitor check, are attempted up to 3 times within a single delivery run, with a short exponential backoff between attempts and a 10-second timeout per attempt:
- Only transient failures are retried (network errors, timeouts, HTTP 5xx, and 429)
- Redirects (3xx) and other 4xx responses are terminal: they are not retried
- For job, verification and credit events all attempts share one time limit, so after a timeout a retry can get less than 10 seconds, or not run at all
- After the final failed attempt the delivery is recorded as failed and is not attempted again automatically
An alert from a manual monitor check (Check now in the dashboard, POST /v1/blacklist/monitors/{id}/check, or POST /v1/dmarc/monitors/{id}/check) is sent while that request waits for its answer, so it is attempted once, with a 5-second timeout. If that attempt fails, it is recorded as failed in your webhook logs like any other delivery.
A registered endpoint's deliveries are recorded in its webhook logs. A per-job webhook_url has no registered endpoint and so no webhook log: its delivery is recorded in the dashboard under Settings → Notifications → History. The entry is marked Sent or Failed, and a failed one gives the status code or error and how many attempts were made.
Credit events (credits.low, credits.exhausted) are raised by the API request that crossed the threshold and delivered in the background moments later, so that request never waits on your endpoint. A missed credit event is recorded in your webhook logs like any other failure; your balance is always available from GET /v1/usage/summary.
There is no hours-long redelivery schedule, so treat webhooks as best-effort notifications: acknowledge quickly with a 2xx and poll GET /v1/jobs/{job_id} as a fallback if you miss a delivery. Failed registered-webhook deliveries can also be retried manually via POST /v1/webhooks/{id}/logs/{logId}/retry; a failed per-job webhook_url delivery cannot be retried. A manual retry re-sends the original body, including its original timestamp, with a fresh t in X-Kawaa-Signature-V2, so it passes the 300-second window however old the delivery is.
Best practices
Respond quickly
Return a 2xx response within 5 seconds. An alert from a manual monitor check waits only 5 seconds and is not retried; other deliveries wait up to 10 seconds per attempt, and a retry can get less. Process the webhook asynchronously if needed.
Handle duplicates
Webhooks may be delivered multiple times, and a replay inside the signature window still verifies. Deduplicate on the event type plus a stable identifier from the payload (e.g. data.job_id).
Verify signatures
Always validate X-Kawaa-Signature-V2 and its 300-second window to ensure the request is from Kawaa and is not an old one replayed.