Webhooks and events
Everything that happens to a message produces an event. You can pull events (GET /v1/events, GET /v1/inboxes/{id}/events) or have them pushed: webhooks for your backend, Server-Sent Events or WebSocket for a live agent loop.
Webhooks
Register
curl -X POST "$API/v1/webhooks" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/mailgentic",
"tenant_id": "TENANT_ID",
"event_types": ["delivered", "bounced", "complained", "message.received", "approval.required"]
}'
urlmust be HTTPS and public; private and loopback targets are rejected.tenant_idrestricts delivery to one tenant; omit it for the whole account.- An empty
event_typesarray subscribes to everything. - The response includes the signing
secretonce.
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/webhooks |
List (no secrets) |
PATCH |
/v1/webhooks/{id} |
Enable / disable |
DELETE |
/v1/webhooks/{id} |
Delete |
GET |
/v1/webhooks/{id}/attempts |
Inspect attempts, status codes and bodies |
POST |
/v1/webhooks/{id}/replay |
Re-queue failed and dead attempts |
Delivery
Each event is POSTed as JSON with these headers:
X-Cyfox-Event: delivered
X-Cyfox-Event-Id: 1842
X-Cyfox-Delivery-Attempt: 1
X-Cyfox-Signature: t=1759574400,v1=5f1a…
Delivery is at-least-once with exponential back-off. Respond 2xx within a few seconds; anything else is retried, and exhausted attempts are marked dead and can be replayed. Deduplicate on X-Cyfox-Event-Id.
Verify the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw bytes, before any JSON parsing, and reject timestamps older than five minutes.
import hmac, hashlib, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in headers["X-Cyfox-Signature"].split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, sig: string, rawBody: Buffer): boolean {
const { t, v1 } = Object.fromEntries(sig.split(",").map(p => p.split("=", 2)));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Payload
{
"id": 1842,
"type": "delivered",
"tenant_id": "TENANT_ID",
"message_id": "MESSAGE_ID",
"delivery_id": "DELIVERY_ID",
"recipient": "customer@example.net",
"data": { "smtp_response": "250 2.0.0 OK", "tags": ["transactional"], "metadata": {"order_id": "8842"} },
"created_at": "2026-10-04T10:00:03Z"
}
Event types
Delivery (relay and inbox sends) — accepted, held, released, delivered, deferred, bounced, blocked, complained, suppressed, expired, cancelled, unsubscribed, suspended, resumed, anomaly_detected, auth_failed, rejected. Details in Sending email.
Inbox — message.received (plus verdict-specific message.received.clean, .suspicious, .spam, .blocked, .unauthenticated), message.sent, message.moved, message.deleted, message.draft_saved, message.scheduled, message.draft_sent, message.enriched, approval.required, approval.approved, approval.rejected.
Server-Sent Events
GET /v1/inboxes/{id}/events returns text/event-stream. Resume after a disconnect with the Last-Event-ID header or ?after_id=.
curl -N "$API/v1/inboxes/$INBOX_ID/events?after_id=1842" -H "Authorization: Bearer $AGENT_KEY"
event: message.received
id: 1843
data: {"id":1843,"type":"message.received","recipient":"support@agents.example.com","data":{"inbox_id":"INBOX_ID","message_id":"MESSAGE_ID"},"created_at":"2026-10-03T18:01:00Z"}
The server sends keep-alive comments; reconnect with the last ID you processed.
WebSocket
GET /v1/inboxes/{id}/ws delivers the same event objects as JSON text frames. Authenticate with either:
Authorization: Bearer <token>where your client supports headers, orSec-WebSocket-Protocol: cyfox.<token>from browsers.
Never place the token in the query string. Resume with after_id. The server pings every 25 seconds and allows up to five concurrent streams per key.
const ws = new WebSocket(`wss://api.mailgentic.ai/v1/inboxes/${inboxId}/ws?after_id=${lastId}`, [`cyfox.${token}`]);
ws.onmessage = (e) => handle(JSON.parse(e.data));