Outbound webhooks and signature verification
Subscribing to events, the delivery and retry contract, and verifying the HMAC signature with a working code sample.
Outbound webhooks push events to your endpoint as they happen, so your CRM or data warehouse stays in sync without polling. Configure them under Settings → Webhooks: an HTTPS URL, the events you want, and the signing secret Eklow generates for the endpoint.
Events
| Event | Fires when |
|---|---|
| message.sent | A campaign or warmup message was handed to the provider |
| email.opened | A tracked open was recorded |
| email.clicked | A tracked link was clicked |
| email.replied | A reply was detected and classified as human |
| email.bounced | A hard or soft bounce was recorded |
| contact.unsubscribed | An unsubscribe was processed and suppressed |
| placement_test.completed | A placement test finished, expired, failed or was stopped; status says which |
{
"id": "evt_01j9x7v2m8",
"type": "email.replied",
"created_at": "2026-08-04T14:22:07Z",
"workspace_id": "b1f4...",
"data": {
"message_id": "a2c8...",
"campaign_id": "6b9d...",
"contact_id": "e91f...",
"mailbox_id": "77aa..."
}
}Delivery and retries
Deliveries are POST requests with a JSON body. Your endpoint should return a 2xx within 10 seconds — do real processing async and respond fast. Non-2xx responses and timeouts are retried with exponential backoff — 8 attempts at 30s, 1m, 2m, 4m, 8m, 16m, 32m, so roughly an hour — before the delivery is marked failed. After 10 failed deliveries in a rolling 7 days the endpoint is switched off, and turning it back on is a manual step in Settings → Webhooks. Deliveries can arrive out of order and, rarely, more than once — dedupe on the event id.
Verifying the signature
Every delivery is signed with your endpoint's secret so you can prove it came from Eklow and wasn't tampered with. The X-Eklow-Signature header reads t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of t + "." + rawBody. Verify like this:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(
rawBody: string,
header: string,
secret: string
): boolean {
const parts = new Map(header.split(",").map((part) => part.split("=") as [string, string]));
const timestamp = parts.get("t") ?? "";
const signature = parts.get("v1") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}- Compute the HMAC over the raw request body exactly as received — parse-then-restringify changes whitespace and breaks the signature. In most frameworks that means reading the body before any JSON middleware touches it.
- The timestamp check (5-minute window) defeats replay of captured deliveries.
- Use a constant-time comparison (
timingSafeEqualabove), never===on the hex strings. - Rotating the secret from Settings → Webhooks invalidates the old one after a 24-hour overlap window in which deliveries are signed with both (two signatures, comma-separated) so you can roll without downtime.
Was this page helpful?
Related articles
- API keys, authentication, and the error envelopeCreating keys, Bearer auth, pagination and rate limits, the standard error shape, and a first request in curl.API & Webhooks
- Quick start: zero to first campaignThe six steps between creating a workspace and launching your first sequence, and how long each one really takes.Getting Started
- Add and verify a domainWalk through each generated DNS record, where to paste it at common registrars, and what to do while propagation runs.Domains & DNS


