Mailgentic

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"]
  }'
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:

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));