# SendForAI docs

SendForAI is one JSON API for transactional email (one message to one person) and broadcasts (one message to a list), built so an AI agent can set it up and run it. Amazon SES underneath, sold at cost.

- Base URL: `https://api.sendforai.com`
- Index of everything: `GET https://api.sendforai.com/v1`
- OpenAPI: https://sendforai.com/openapi.json
- Short version of this page: https://sendforai.com/llms.txt
- Pricing as markdown: https://sendforai.com/pricing.md, as JSON: `GET /v1/pricing`
- Health: `GET /v1/status`

There are no API keys to request and no dashboard. Your identity is an Ed25519 key. The first authenticated request creates the account.

## Fastest start (no domain, no DNS, no human)

```bash
npx sendforai sandbox
# -> { "domain": "k3f9c...sandbox.sendforai.com", "key_file": "~/.sendforai/k3f9c....key" }

npx sendforai send \
  --from agent@k3f9c...sandbox.sendforai.com \
  --to delivered@simulator.sendforai.com \
  --subject "Hello" --markdown "It works."
```

A sandbox identity can use the whole API, but only reaches the simulator. To send real email, add a domain you control (see "Domains").

The CLI prints JSON and never prompts. `npx sendforai --help` lists every command.

### The same start in Python, no npm (`pip install cryptography`)

```python
import base64, hashlib, json, time, urllib.request
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

API = "https://api.sendforai.com"

# 1. A key, and the sandbox identity named after it. Save the PEM somewhere private.
key = Ed25519PrivateKey.generate()
pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
pub = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
domain = "k" + hashlib.sha256(pub).hexdigest()[:24] + ".sandbox.sendforai.com"

# 2. A connection token, signed locally. Nothing is registered anywhere first.
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
now = int(time.time())
payload = {"d": domain, "iat": now, "exp": now + 30 * 86400, "p": base64.b64encode(pub).decode()}
signed = "sfat1." + b64u(json.dumps(payload).encode())
token = signed + "." + b64u(key.sign(signed.encode()))

# 3. Send to the simulator. The first authenticated request creates the account.
body = json.dumps({"from": f"agent@{domain}", "to": "delivered@simulator.sendforai.com",
                   "subject": "Hello", "markdown": "It works."}).encode()
req = urllib.request.Request(API + "/v1/emails", data=body, method="POST",
                             headers={"Authorization": "Bearer " + token, "content-type": "application/json"})
print(json.load(urllib.request.urlopen(req))["status"])   # "sent"
```

Keep `pem` (the private key) and reuse it: the same key always gives the same sandbox identity and account.

## Ways to connect

| You are | Use |
|---|---|
| A shell-driven agent | `npx sendforai <command>` |
| Node code | `npm i @sendforai/sdk` |
| An MCP client that can run a local process | `npx -y @sendforai/mcp` (see "MCP") |
| An MCP client that connects to a URL | `https://sendforai.com/mcp` with a connection token (see "MCP") |
| Any other language, curl, no-code tools | Plain HTTP with a connection token (see "Signing") |

## Sandbox

A sandbox identity is named after its own key, so it needs no DNS:

```
k<first 24 hex chars of sha256(raw 32-byte public key)>.sandbox.sendforai.com
```

- Sign requests exactly as for a domain (below), with that name as the domain.
- On signed requests also send `SendForAI-Public-Key: <raw public key, base64>`. Connection tokens for sandbox identities already carry the key.
- Sends are accepted only to `@simulator.sendforai.com`. Anything else returns `409 sandbox_identity` with the steps to add a real domain.
- Simulator addresses: `delivered@`, `bounced@`, `complained@` produce `email.delivery`, `email.bounce` and `email.complaint` events, the same names real mail produces. Any other local part is delivered. Simulated sends are free, never leave SendForAI, and are not capped.
- Adding a real domain to a sandbox account keeps the same key, balance and contacts.

## Signing

Two credentials are accepted. Both prove you hold the identity's private key.

### Option 1: connection token (simplest for curl and other languages)

A token is minted locally with the private key. Nothing is stored on the server. It expires on its own (30 days by default, 90 at most) and stops working if the key is rotated. Treat it like a password.

```bash
TOKEN=$(npx sendforai token --days 30 | jq -r .token)

curl https://api.sendforai.com/v1/emails \
  -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"from":"hi@example.com","to":"ada@example.org","subject":"Hello","markdown":"Hi.","max_cost_usd":0.01}'
```

Format, if you mint it yourself: `sfat1.<payload>.<signature>`

- `payload` = base64url (no padding) of the JSON `{"d":"<identity domain>","iat":<unix seconds>,"exp":<unix seconds>}`. Sandbox identities add `"p":"<raw public key, base64>"`.
- `signature` = base64url (no padding) of the Ed25519 signature over the ASCII bytes of `sfat1.<payload>`.
- `exp - iat` may be at most 7,776,000 seconds (90 days).

Python (`pip install cryptography`):

```python
import base64, json, time
from cryptography.hazmat.primitives import serialization

key = serialization.load_pem_private_key(open("sendforai.key", "rb").read(), password=None)
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
now = int(time.time())
signed = "sfat1." + b64u(json.dumps({"d": "example.com", "iat": now, "exp": now + 30 * 86400}).encode())
token = signed + "." + b64u(key.sign(signed.encode()))
```

### Option 2: sign every request

Send three headers:

```
SendForAI-Domain: example.com
SendForAI-Timestamp: <unix seconds, within 300s of server time>
SendForAI-Signature: sfa1=<base64 Ed25519 signature>
```

The signature is over this string, six lines joined by `\n`, no trailing newline:

```
sfa1
<METHOD, uppercase>
<path including query string, e.g. /v1/events?limit=10>
<the same timestamp>
<domain, lowercase>
<SHA-256 of the exact request body bytes, lowercase hex; for an empty body, the hash of the empty string>
```

Python:

```python
import base64, hashlib, json, time, urllib.request
from cryptography.hazmat.primitives import serialization

key = serialization.load_pem_private_key(open("sendforai.key", "rb").read(), password=None)
domain, method, path = "example.com", "POST", "/v1/emails"
body = json.dumps({"from": "hi@example.com", "to": "ada@example.org", "subject": "Hello", "text": "Hi."}).encode()
ts = str(int(time.time()))
canonical = "\n".join(["sfa1", method, path, ts, domain, hashlib.sha256(body).hexdigest()])
sig = base64.b64encode(key.sign(canonical.encode())).decode()
req = urllib.request.Request("https://api.sendforai.com" + path, data=body, method=method, headers={
    "SendForAI-Domain": domain, "SendForAI-Timestamp": ts, "SendForAI-Signature": "sfa1=" + sig,
    "content-type": "application/json"})
print(urllib.request.urlopen(req).read().decode())
```

Shell (OpenSSL 3 or newer):

```bash
openssl genpkey -algorithm ed25519 -out sendforai.key
PUB=$(openssl pkey -in sendforai.key -pubout -outform DER | tail -c 32 | base64)   # goes in DNS

DOMAIN=example.com; METHOD=GET; REQ_PATH=/v1/account; BODY=''
TS=$(date +%s)
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')
printf 'sfa1\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$REQ_PATH" "$TS" "$DOMAIN" "$HASH" > /tmp/sfa-canonical
SIG=$(openssl pkeyutl -sign -inkey sendforai.key -rawin -in /tmp/sfa-canonical | base64 | tr -d '\n')
curl "https://api.sendforai.com$REQ_PATH" \
  -H "SendForAI-Domain: $DOMAIN" -H "SendForAI-Timestamp: $TS" -H "SendForAI-Signature: sfa1=$SIG"
```

Node: `new SendForAI({ domain, privateKey })` from `@sendforai/sdk` signs for you. `SendForAI.sandbox()` returns a ready client with a new sandbox identity.

## Domains

### Identity domain

Publish the public key (raw 32 bytes, base64) as a TXT record. Whoever controls the domain's DNS controls the account.

```
_sendforai.example.com  TXT  "v=sfa1; k=ed25519; p=<public key>"
```

`GET /v1/domains/{domain}/setup?p=<public key>` (public) returns the exact record. `npx sendforai init example.com` generates the key and prints it.

### Sending records

To send real mail, Amazon SES also needs DKIM and MAIL FROM records. `POST /v1/domains/{domain}/verify` creates the SES identity and returns them; `GET /v1/domains/{domain}` shows their status. Until SES has verified the domain, a send returns `409 domain_not_verified` listing the records still missing.

### Automatic DNS

With a Vercel or Cloudflare token in the environment (`VERCEL_TOKEN` plus `VERCEL_TEAM_ID` for team domains, or `CLOUDFLARE_API_TOKEN`), one call publishes the identity or delegation record, the DKIM and MAIL FROM records, and a DMARC record if the domain has none. The token is used on your machine and is never sent to SendForAI. Safe to run again.

```bash
npx sendforai dns setup --domain example.com --wait
```

SDK: `sfa.dns.setup({ provider: dnsProviderFromEnv(), waitForVerification: true })`. Local MCP: `setup_dns`. On any other DNS host, `GET /v1/domains/{domain}` lists the records for a person to add.

### More domains on one account (and graduating from a sandbox)

One key can send for up to 50 domains. On each extra domain publish a delegation record pointing at your identity (a domain, or a sandbox name), then add it:

```
_sendforai.news.example.org  TXT  "v=sfa1; delegate=<your identity domain>"
```

```bash
npx sendforai domains add news.example.org      # POST /v1/domains {"name": "..."}
npx sendforai domains verify news.example.org   # DKIM + MAIL FROM records to publish
```

Subdomains of a domain you have are covered automatically. Balance and allowance are shared; reputation, warmup and daily caps stay per domain.

## Send an email

`POST /v1/emails`

| Field | Notes |
|---|---|
| `from` | An address on any of the account's domains. `"Name <a@b.com>"` is allowed. |
| `to` | One address or an array of up to 50. |
| `subject` | Up to 998 characters. |
| `markdown`, `html`, `text` | At least one. Markdown is rendered to HTML with a plain-text part. |
| `reply_to`, `headers` | Optional. |
| `stream` | `transactional` (default) or `broadcast` (adds one-click unsubscribe). |
| `dry_run` | `true` plans the send: cost, capacity and lint warnings, nothing sent. |
| `max_cost_usd` | The send is refused with 402 if it would bill more. |
| `idempotency_key` | A retry with the same key returns the original result; nothing is sent twice. |

Response (201):

```json
{
  "id": "3f9c2a7e-...",
  "status": "sent",
  "recipients": [{ "to": "ada@example.org", "status": "accepted" }],
  "cost": { "emails": 1, "ses_usd": 0.0001, "billed_usd": 0.00011, "included_remaining": 9999, "note": "..." },
  "capacity": { "daily_cap": 1000, "remaining_today": 999 },
  "lint": [],
  "receipt": "<signed record of what was sent and billed>"
}
```

Addresses that previously bounced, complained or unsubscribed come back with status `suppressed` and are not sent to. A dry run of a send that would start a sending month shows `cost.month_fee_usd`.

## Money

- $0 to build: unlimited simulated sends, and 25 real emails a day to addresses at your own domains.
- The first send in a calendar month (UTC) to anyone outside your own domains starts a sending month: $5 from the prepaid balance, 10,000 emails included. Months with no outside sends cost $0.
- After the included emails: $0.11 per 1,000.
- `GET /v1/account` returns the balance, the budget used and remaining, usage, and every domain. Check it before a large send.
- `POST /v1/account/topup {"amount_usd": 10, "auto_topup": true}` returns a `checkout_url`. Hand it to a person; this is the one step that needs a human. `account.topped_up` appears in the event log when they pay.
- Usage charges in a month never exceed the budget ($20 by default). `POST /v1/account/budget {"monthly_usd": n}`: lowering applies immediately; raising returns an `approval_url` for a person.
- `POST /v1/account/auto-topup {"enabled": false}` turns auto top-up off or changes its amount. `GET /v1/account/billing` returns a billing portal link for a person.

## Contacts and broadcasts

- `POST /v1/contacts` upserts one contact by email (`data` is any JSON you can filter on; `subscribed` flag). `GET /v1/contacts` pages with `limit` and `cursor`. `POST /v1/contacts/unsubscribe`. `POST /v1/contacts/import` takes up to 10,000 per call in a sending month. Contacts are unlimited and free.
- `POST /v1/broadcasts` creates a broadcast to every subscribed contact, or those matching `filter`. By default it is `awaiting_approval` and returns an `approval_url` for a person to click. Pass `require_approval: false` if your workflow has its own sign-off.
- `POST /v1/broadcasts/{id}/send` starts an approved broadcast in the background (202). `GET /v1/broadcasts/{id}` shows status and `progress` (sent, queued, failed, cancelled, resumes_at). `POST /v1/broadcasts/{id}/cancel` stops it, also mid-send; the unsent part is refunded.

## Events

`GET /v1/events?cursor=&limit=&type=` is a pullable log, so no webhook endpoint is needed. Keep `next_cursor` and pass it back to get only what is new. Types include `email.sent`, `email.delivery`, `email.bounce`, `email.complaint`, `email.failed`, `broadcast.awaiting_approval`, `broadcast.approved`, `broadcast.rejected`, `broadcast.sending`, `broadcast.deferred`, `broadcast.sent`, `broadcast.cancelled`, `account.month_started`, `account.topped_up`, `account.budget_changed`, `account.auto_topup_failed`, `domain.added`, `domain.paused`, `contacts.imported` and `contact.unsubscribed`.

## Capacity and warmup

`GET /v1/capacity` returns today's cap, what has been sent, what remains and when it resets. `GET /v1/usage` returns the month so far. New sending domains start with a daily cap that rises in stages while bounce and complaint rates stay low. A domain, or a whole account, whose rates get too high is paused, and the error says how to clean the list.

## Errors

Every error has the same shape, and `fix` is written for an agent to act on:

```json
{ "error": { "code": "balance_required", "message": "...", "fix": "...", "details": {}, "docs": "..." } }
```

| Status and code | What to do |
|---|---|
| 401 `unauthenticated` | Sign the request or send a valid token. `details` says what was wrong (clock, key mismatch, missing DNS record). |
| 400 `bad_request` | Fix the fields in `details.issues`. If `from` is not on your domains, `details.record` is the delegation record to publish. |
| 402 `balance_required` | `POST /v1/account/topup` and hand `checkout_url` to your operator. Simulator and own-domain sends keep working. |
| 402 `budget_exceeded` | Send less, wait for next month, or `POST /v1/account/budget` for an approval link. Also returned when a send exceeds `max_cost_usd`. |
| 409 `sandbox_identity` | Sandbox identities only reach the simulator. Add a real domain. |
| 409 `domain_not_verified` | Publish the records in `details.records`, then verify again. |
| 409 (other codes) | The broadcast needs approval, a domain is taken, or the domain limit is reached; the `fix` says which. |
| 422 `send_rejected` | Amazon SES refused every recipient. Nothing was sent or billed. `details.recipients` has the reason per address. |
| 429 `capacity_exceeded` | Today's cap is used. `details.resets_at` says when it resets. |
| 404 `not_found` | Unknown endpoint or id. |

## MCP

Hosted (nothing to install; Streamable HTTP):

```json
{
  "mcpServers": {
    "sendforai": {
      "url": "https://sendforai.com/mcp",
      "headers": { "Authorization": "Bearer <connection token>" }
    }
  }
}
```

Claude Code: `claude mcp add --transport http sendforai https://sendforai.com/mcp --header "Authorization: Bearer <connection token>"`

Get the token with `npx sendforai token`. The hosted server authenticates with that bearer token, not OAuth; a client that only supports OAuth connectors should use the local server. `npx sendforai mcp` prints both configs filled in.

Local (holds the key itself, and adds `generate_identity`, `setup_dns` and `create_connection_token`):

```json
{
  "mcpServers": {
    "sendforai": {
      "command": "npx",
      "args": ["-y", "@sendforai/mcp"],
      "env": { "SENDFORAI_DOMAIN": "example.com", "SENDFORAI_PRIVATE_KEY_FILE": "/path/to/sendforai.key" }
    }
  }
}
```

No identity yet? Start the local server without env and call `generate_identity` (omit `domain` for a sandbox identity).

Tools: `whoami`, `get_account`, `get_pricing`, `get_capacity`, `get_usage`, `plan_email`, `send_email`, `list_domains`, `add_domain`, `get_domain_status`, `verify_domain`, `upsert_contact`, `list_contacts`, `import_contacts`, `create_broadcast`, `get_broadcast`, `send_broadcast`, `cancel_broadcast`, `add_credit`, `set_budget`, `set_auto_topup`, `billing_portal`, `list_events`.

## Claude Code plugin

The plugin adds the SendForAI skill (the whole set-up-and-send workflow) and the local MCP server:

```
/plugin marketplace add https://sendforai.com/marketplace.json
/plugin install sendforai@sendforai
```

From a shell: `claude plugin install sendforai --marketplace https://sendforai.com/marketplace.json`. The skill on its own is at https://sendforai.com/skill.md.

## Limits worth knowing

- SendForAI sends email; it does not receive it. There are no inboxes.
- The SDK is Node only. Other languages use plain HTTP with a connection token.
- New sending domains warm up, so a new domain cannot take a large volume on day one.
- Open and click tracking are not offered.

## Endpoint list

| Endpoint | Auth | What it does |
|---|---|---|
| `GET /v1` | public | Index of docs, spec, MCP and status. |
| `GET /v1/pricing` | public | Pricing as JSON. |
| `GET /v1/status` | public | Health check. |
| `GET /v1/domains/{domain}/setup?p=` | public | The identity TXT record for a public key. |
| `POST /v1/emails` | yes | Send, or plan with `dry_run`. |
| `GET /v1/account` | yes | Balance, budget, usage, domains. |
| `POST /v1/account/topup` | yes | Checkout link for a person to add credit. |
| `POST /v1/account/budget` | yes | Lower the budget, or get an approval link to raise it. |
| `POST /v1/account/auto-topup` | yes | Turn auto top-up off or change its amount. |
| `GET /v1/account/billing` | yes | Billing portal link for a person. |
| `GET /v1/domains`, `POST /v1/domains` | yes | List and add sending domains. |
| `GET /v1/domains/{domain}` | yes | Identity and SES status, records to publish. |
| `POST /v1/domains/{domain}/verify` | yes | Create the SES identity; returns DKIM and MAIL FROM records. |
| `GET /v1/capacity`, `GET /v1/usage` | yes | Today's cap; month to date. |
| `POST /v1/contacts`, `GET /v1/contacts` | yes | Upsert and list contacts. |
| `POST /v1/contacts/import`, `POST /v1/contacts/unsubscribe` | yes | Bulk import; unsubscribe. |
| `POST /v1/broadcasts`, `GET /v1/broadcasts/{id}` | yes | Create and read a broadcast. |
| `POST /v1/broadcasts/{id}/send`, `POST /v1/broadcasts/{id}/cancel` | yes | Start and cancel. |
| `GET /v1/events` | yes | Event log. |

Questions: hello@sendforai.com. Abuse reports: abuse@sendforai.com.
