Usage Guide

The everyday workflows for sending, receiving, threading, and webhook delivery.

This guide assumes the Worker is deployed and SENDOOK_HOST + SENDOOK_MASTER_KEY are set. All examples use curl; any HTTP client works.

Core concepts

Agents

An agent is a self-contained mailbox. Each agent has:

  • A 12-character lowercase id (abc123def456).
  • An address derived from <id>@${DEFAULT_EMAIL_DOMAIN}.
  • A per-agent API key (returned only once, at creation).
  • Its own Durable Object instance with its own SQLite for messages, threads, recipients, and webhook attempt logs.
  • Its own webhook subscriptions.

The master key can do anything across all agents (create, list, delete, send-as, configure webhooks). The per-agent key can only act on its own agent.

Messages

A message is one email row. Direction is either inbound (received via Email Routing) or outbound (sent via the Send Email binding). The aggregate status is one of:

  • received — inbound email, persisted.
  • sent — outbound, every envelope recipient succeeded.
  • partial — outbound, some recipients succeeded and some failed.
  • rejected — outbound, every recipient failed.
  • pending — outbound, mid-fanout (transient).

Each outbound message also writes one row per envelope recipient to message_recipients so a future retry can target the failed subset.

Threads

Inbound mail joins an existing thread when its In-Reply-To (or any References entry) matches the Message-ID of a previously stored message. Otherwise it starts a new thread. Outbound replies aren't auto-linked yet — see sendook-sjq for the in-flight follow-up.

Webhooks

Each agent can have any number of HTTPS webhook subscriptions. Two events are emitted:

  • message.sent — fires after at least one envelope recipient succeeded.
  • message.received — fires after an inbound email is persisted (deduped by Message-ID).

Every fanout request carries:

  • x-sendook-event: message.sent | message.received
  • x-sendook-signature: sha256=<hex-hmac> — HMAC-SHA-256 of the JSON body keyed on the webhook's secret.
  • x-sendook-webhook-id: <uuid>
  • x-sendook-attempt: 1..5

Common workflows

1. Create an agent

curl -sX POST "$SENDOOK_HOST/agents" \
  -H "Authorization: Bearer $SENDOOK_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Customer Success"}' | jq

Response (201 Created):

{
  "id": "abc123def456",
  "email": "abc123def456@agents.yourdomain.com",
  "name": "Customer Success",
  "api_key": "rA9-...long-base64url",
  "created_at": 1730000000
}
Save the api_key immediately. It is the only time the plain key is shown — only the HMAC hash is stored.

2. Send an email

With the master key:

curl -sX POST "$SENDOOK_HOST/agents/abc123def456/messages/send" \
  -H "Authorization: Bearer $SENDOOK_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "alice@example.com",
    "cc": ["bob@example.com"],
    "subject": "Welcome",
    "text": "Plain text version.",
    "html": "<p>HTML version.</p>"
  }'

With the per-agent key (substitute the Authorization header):

-H "Authorization: Bearer $AGENT_API_KEY"

Response (202 Accepted for sent/partial, 502 Bad Gateway for rejected):

{
  "id": "0f7d...uuid",
  "status": "sent",
  "message_id_header": "<uuid@agents.yourdomain.com>",
  "recipients": [
    { "recipient": "alice@example.com", "status": "sent" },
    { "recipient": "bob@example.com", "status": "sent" }
  ]
}

If the binding rejects one recipient mid-fanout, status becomes partial, the row reports the failed recipient with its error, and a message.sent webhook still fires (since at least one recipient succeeded).

3. Send with attachments

Attachments are base64-encoded; up to 10 per message, ~5 MiB each, capped to a 25 MiB composed MIME (the Cloudflare Send Email provider limit).

DATA=$(base64 -w0 < report.pdf)
curl -sX POST "$SENDOOK_HOST/agents/abc123def456/messages/send" \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"to\": \"alice@example.com\",
    \"subject\": \"Q3 report\",
    \"text\": \"See attached.\",
    \"attachments\": [{
      \"filename\": \"report.pdf\",
      \"contentType\": \"application/pdf\",
      \"data\": \"$DATA\"
    }]
  }"

4. Receive emails

Inbound mail to <agent-id>@${DEFAULT_EMAIL_DOMAIN} is parsed with postal-mime, deduped by Message-ID, threaded, persisted, and queued for webhook fanout.

Read the agent's mailbox:

curl -s "$SENDOOK_HOST/agents/abc123def456/messages?limit=20" \
  -H "Authorization: Bearer $AGENT_API_KEY" | jq

Response shape:

{
  "messages": [
    {
      "id": "uuid",
      "direction": "inbound",
      "from_addr": "alice@example.com",
      "to_addr": "abc123def456@agents.yourdomain.com",
      "subject": "Hi",
      "status": "received",
      "raw_size": 1234,
      "created_at": 1730000000,
      "thread_id": "uuid"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

5. Walk a thread

curl -s "$SENDOOK_HOST/agents/abc123def456/threads/<thread-uuid>" \
  -H "Authorization: Bearer $AGENT_API_KEY" | jq

Returns the thread row plus its messages in chronological order, each with full body_text and body_html.

6. Subscribe a webhook

curl -sX POST "$SENDOOK_HOST/agents/abc123def456/webhooks" \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/sendook/webhook",
    "events": ["message.received", "message.sent"]
  }'

Response (the secret is returned exactly once):

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

The Worker rejects URLs that point at loopback / RFC1918 / link-local / metadata IPs, plus localhost, *.localhost, *.local, and metadata.google.internal. Public HTTPS URLs only.

7. Verify a webhook in your handler

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

export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("x-sendook-signature") ?? "";
  const expected = "sha256=" + createHmac("sha256", process.env.SENDOOK_WEBHOOK_SECRET!)
    .update(body)
    .digest("hex");
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("bad signature", { status: 401 });
  }

  const event = JSON.parse(body);
  // event.event, event.agent_id, event.message_id, event.message_id_header,
  // event.delivered_at, event.data (the hydrated message — body_text/body_html
  // may be truncated with a marker for messages over 256 KiB)
  return new Response("ok");
}

8. Inspect webhook delivery attempts

curl -s "$SENDOOK_HOST/agents/abc123def456/webhooks/<webhook-id>/attempts?limit=20" \
  -H "Authorization: Bearer $AGENT_API_KEY" | jq

Each row records status_code (or null for network failures), ok, attempt_count (1..5), and payload_size in UTF-8 bytes. Only the last 100 attempts per webhook are retained; older rows are evicted on every new insert.

9. Delete an agent

curl -sX DELETE "$SENDOOK_HOST/agents/abc123def456" \
  -H "Authorization: Bearer $SENDOOK_MASTER_KEY"

The Worker tombstones the agent in D1 (deleted_at set), purges all per-agent SQLite tables in the DO via markDeleted(), and hard-deletes the agent's webhooks. Subsequent API calls behave as if the agent never existed (404 on read, 401 on auth with the agent's old key).

Best practices

Verify every webhook

The HMAC signature is the only thing standing between an attacker and a forged event. Always verify before acting.

Treat the master key like AWS root

Anyone with the master key can create or delete arbitrary agents. Store it in a secrets manager and prefer per-agent keys for any service that only needs to act on its own mailbox.

Use idempotency on inbound webhooks

The Worker dedupes inbound mail by Message-ID so each unique inbound message fires at most one webhook per subscriber. But your handler may still see duplicates if it returns 5xx and Sendook retries — guard with a seen-set keyed on (agent_id, message_id).

Watch the webhook attempts log

A webhook stuck on 5xx will retry 5 times per event. Long-running outages will fill the per-webhook attempt log; check /webhooks/:id/attempts after a recovery to confirm deliveries are succeeding again.