This guide assumes the Worker is deployed and SENDOOK_HOST + SENDOOK_MASTER_KEY are set. All examples use curl; any HTTP client works.
An agent is a self-contained mailbox. Each agent has:
abc123def456).<id>@${DEFAULT_EMAIL_DOMAIN}.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.
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.
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.
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.receivedx-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..5curl -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
}
api_key immediately. It is the only time the plain key is shown — only the HMAC hash is stored.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).
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\"
}]
}"
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
}
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.
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.
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");
}
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.
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).
The HMAC signature is the only thing standing between an attacker and a forged event. Always verify before acting.
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.
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).
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.