Webhooks

Subscribe to per-agent events; HMAC payload, retry semantics, attempt log.

Webhooks are scoped to a single agent. Two events are emitted:

EventFires when
message.sentAfter at least one envelope recipient succeeded on POST /agents/:id/messages/send.
message.receivedAfter an inbound email is parsed, deduplicated by Message-ID, and persisted.

The Worker fans out webhooks in parallel across subscribers. A slow or failing subscriber cannot delay other subscribers on the same event.

Create a webhook

  • Method: POST
  • Path: /agents/:id/webhooks
  • Auth: master key, or this agent's own per-agent key

Request body

{
  "url": "https://your-app.com/sendook/webhook",
  "events": ["message.received", "message.sent"],
  "secret": "optional-32-byte-secret-or-omit-to-generate"
}
FieldTypeRequiredDescription
urlstringYesHTTPS URL ≤ 2048 chars. See URL restrictions.
eventsstringYes1–16 entries from message.sent, message.received.
secretstringNo16–256 chars. If omitted, a 32-byte cryptographically-random base64url secret is generated.

Body cap: 4 KiB UTF-8 bytes.

URL restrictions

The Worker rejects URLs that resolve to:

  • IPv4 loopback 127/8, RFC1918 10/8 172.16/12 192.168/16, link-local + cloud metadata 169.254/16, CGNAT 100.64/10, multicast 224/4, 0/8, reserved 240/4.
  • IPv6 loopback ::1, link-local fe80::/10, ULA fc00::/7, multicast ff::/8, IPv4-mapped variants of any of the above.
  • Hostnames localhost, *.localhost, *.local, metadata, metadata.google.internal.

http:// is also rejected — only https://. The same check runs at delivery time so webhooks created before this guard cannot be exploited.

Response 201 Created

{
  "id": "8d7c...uuid",
  "url": "https://your-app.com/sendook/webhook",
  "events": ["message.received", "message.sent"],
  "secret": "rA9-...long-base64url",
  "created_at": 1730000000
}

secret is shown exactly once (parallel to the per-agent api_key). Treat it like a credential.

Errors

  • 400 — invalid JSON, body too large, bad URL (non-https, oversized, or SSRF target), unknown event, secret outside length range.
  • 401 / 403 / 404 per the standard auth rules.

List webhooks

  • Method: GET
  • Path: /agents/:id/webhooks
  • Auth: master key, or this agent's own per-agent key

Response 200 OK

{
  "webhooks": [
    {
      "id": "8d7c...uuid",
      "url": "https://your-app.com/sendook/webhook",
      "events": ["message.received", "message.sent"],
      "created_at": 1730000000
    }
  ]
}

secret is never returned by the list endpoint.

Delete a webhook

  • Method: DELETE
  • Path: /agents/:id/webhooks/:webhookId
  • Auth: master key, or this agent's own per-agent key

Response 204 No Content

Errors

  • 404 — webhook doesn't exist or doesn't belong to the requested agent.

List webhook attempts

  • Method: GET
  • Path: /agents/:id/webhooks/:webhookId/attempts
  • Auth: master key, or this agent's own per-agent key

Returns up to the last 100 delivery attempts for one webhook. Older rows are evicted on every new insert; older history is not preserved.

Query parameters

ParameterTypeDefaultDescription
limitinteger50Clamped to 1..100.
offsetinteger0Pagination offset.

Response 200 OK

{
  "attempts": [
    {
      "id": "uuid",
      "webhook_id": "8d7c...uuid",
      "event_type": "message.received",
      "payload_size": 1842,
      "status_code": 200,
      "ok": true,
      "attempt_count": 1,
      "next_retry_at": null,
      "created_at": 1730000000
    },
    {
      "id": "uuid-2",
      "webhook_id": "8d7c...uuid",
      "event_type": "message.received",
      "payload_size": 1842,
      "status_code": 503,
      "ok": false,
      "attempt_count": 1,
      "next_retry_at": null,
      "created_at": 1729999900
    }
  ],
  "total": 2,
  "limit": 50,
  "offset": 0
}
FieldTypeDescription
status_codeinteger | nullHTTP status from the subscriber, or null for network failures (DNS, TCP reset, abort).
okbooleanWhether the subscriber returned 2xx.
attempt_countintegerWhich retry this row represents (1..5).
payload_sizeintegerUTF-8 byte length of the JSON body.
created_atintegerUnix seconds.

Webhook payload format

Every fanout request is POST <subscriber.url> with the following JSON body:

{
  "event": "message.received",
  "agent_id": "abc123def456",
  "message_id": "uuid",
  "message_id_header": "<msg@external.example>",
  "delivered_at": 1730000000,
  "data": {
    "id": "uuid",
    "direction": "inbound",
    "from_addr": "alice@example.com",
    "to_addr": "abc123def456@agents.yourdomain.com",
    "subject": "Hi",
    "message_id_header": "<msg@external.example>",
    "in_reply_to": null,
    "body_text": "Plain text body.",
    "body_html": null,
    "raw_size": 1234,
    "status": "received",
    "created_at": 1730000000,
    "thread_id": "uuid"
  }
}

Headers on every fanout request:

HeaderValue
Content-Typeapplication/json
x-sendook-eventmessage.sent or message.received
x-sendook-signaturesha256=<hex> — HMAC-SHA-256 of the raw JSON body, keyed on the webhook's secret.
x-sendook-webhook-idThe webhook's id.
x-sendook-attempt1..5 — the attempt number.

Payload size cap

If data.body_text and/or data.body_html would push the JSON payload over 256 KiB, both fields are truncated (with a binary-search on UTF-8 byte length so multi-byte chars stay intact) and the truncated value ends with the literal marker:

...truncated; GET /agents/:id/messages/:id for full body

Consumers that need the full body should refetch via GET /agents/:id/threads/:threadId.

Verifying signatures

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = "sha256=" +
    createHmac("sha256", secret).update(rawBody).digest("hex");
  if (header.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Use the raw request body for HMAC input — JSON-parsing then re-stringifying will change byte ordering and break the signature.

Retry semantics

  • Up to 5 attempts per fanout, with exponential backoff (base 500ms, capped at 30s, randomized).
  • 2xx responses end retries immediately.
  • Terminal 4xx (everything except 408, 425, 429) ends retries — the row is recorded once and never retried.
  • 408, 425, 429, every 5xx, and network failures are retried.
  • Each attempt is bounded by a 10s AbortController timeout — a hung TCP connection cannot lock up the agent's queue.

Idempotency

Inbound mail is deduplicated on Message-ID per agent, so a Cloudflare Email Routing retry will not produce a second persisted row, a second thread link, or a second message.received webhook. Outbound message.sent is not deduplicated; client-side retries against POST .../messages/send will fire one webhook per successful call.