Webhooks
Get an HTTPS POST the moment things happen — deliveries, opens, clicks, bounces, complaints, subscribes, and unsubscribes — instead of polling the analytics feed. Every delivery is signed so your receiver can prove it came from EmailFlow AI.
The event catalog
Subscribe each endpoint to exactly the events it cares about. Event names are frozen — they never change meaning; new events are only ever added.
| Event | Fires when |
|---|---|
email.delivered | A message is handed to the recipient's mail server. |
email.failed | A message could not be sent. |
email.opened | A recipient opens a tracked message. |
email.clicked | A recipient clicks a tracked link. |
email.bounced | A message bounces (the metadata carries the bounce type). |
email.complained | A recipient files a spam or abuse report. |
contact.subscribed | A contact subscribes to a list (bulk imports deliberately do not fire it). |
contact.unsubscribed | A contact unsubscribes. |
The delivery payload
Every delivery is a JSON POST. id is unique per delivery — store it to deduplicate retries. data carries the campaign, automation, contact, and list identifiers when they apply, plus event-specific meta (clicked URL, bounce type, IP address, user agent, message id):
POST https://example.com/hooks/emailflow
X-EmailFlow-Signature: t=1718200000,v1=5257a869e7...
{
"id": "64a91b2c7de31",
"event": "email.clicked",
"occurred_at": "2026-06-12T09:15:02+00:00",
"data": {
"campaign_uid": "ab12cd34ef",
"subscriber_email": "alice@example.com",
"list_uid": "cd34ef56ab",
"meta": { "url": "https://acme.com/promo", "ip_address": "198.51.100.7" }
}
}
Verifying signatures
When you create a subscription, the response includes a secret — shown exactly once at creation, never in any later response, so store it immediately. Every delivery then carries an X-EmailFlow-Signature header in the form t=<unix timestamp>,v1=<signature> where the signature is HMAC-SHA256(secret, t + "." + raw_body). To verify:
- Split the header into
tandv1. - Compute
HMAC-SHA256(secret, t + "." + raw_body)over the raw request body (before any JSON parsing). - Compare against
v1with a constant-time comparison. - Reject if
tis older than your tolerance (5 minutes is typical) to block replay attacks.
// Node.js — verify a delivery
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
A complete, type-checked version of this receiver ships in the SDK cookbook.
Retries and auto-disable
A delivery succeeds when your endpoint answers any 2xx within the timeout. Anything else is retried up to 3 attempts with backoff of 30 seconds, 5 minutes, then 30 minutes. Respond fast — acknowledge first, process asynchronously.
When all attempts for a delivery fail, the subscription gains a failure strike; any success resets the counter. At 20 consecutive failed deliveries the subscription is automatically disabled, and you get an in-app notification plus an email. Fix your receiver, then re-enable from the UI or with PATCH {"active": true} — re-enabling clears the strike counter. Delivery logs are retained for 30 days.
Managing subscriptions
Manage subscriptions over the API (scope: webhooks) or in the app under Account → API → Webhooks — the UI includes a deliveries drawer with per-attempt status, response codes, and durations.
Endpoint URLs must be HTTPS. Each subscription is limited to your account's data — events from other tenants can never reach your endpoint.
| Method | Endpoint | Description |
|---|---|---|
| GET | /webhooks | All subscriptions on the account. |
| POST | /webhooks | Create a subscription. Required: url (HTTPS), events[]. Returns 201 with the signing secret — shown once. |
| GET | /webhooks/{uid} | One subscription with status and failure counter. |
| PATCH | /webhooks/{uid} | Update url, events, or active. Setting active: true on an auto-disabled subscription re-enables it. |
| DELETE | /webhooks/{uid} | Delete the subscription and its delivery logs. |
| POST | /webhooks/{uid}/test | Send a signed synchronous ping event and return the live result: status, response code, duration. |
| GET | /webhooks/{uid}/deliveries | Paginated delivery log, newest first. Filter with status=. |
# Subscribe an endpoint to engagement events
curl -X POST https://emailflow.ai/api/v1/webhooks \
-H "Authorization: Bearer efa_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/emailflow",
"events": ["email.opened", "email.clicked", "email.bounced"]
}'
# Response (201) — copy the secret now; it is never shown again
{
"data": {
"uid": "7bc2e91d4af52",
"url": "https://example.com/hooks/emailflow",
"events": ["email.opened", "email.clicked", "email.bounced"],
"active": true,
"secret": "b41c0f8a2e..."
}
}
# Fire a test ping and see the live result
curl -X POST https://emailflow.ai/api/v1/webhooks/7bc2e91d4af52/test \
-H "Authorization: Bearer efa_YOUR_KEY"