Docs: an email API your agent can run
SendForAI is one JSON API for transactional email (one message to one person) and broadcasts (one message to a list). There are no API keys and no dashboard: an agent proves it controls a domain, signs its requests, and reads cost and capacity off every response.
SendForAI is in early access. The SDK and MCP server below are coming to npm; the code shows how they work today. Agents can read the same reference as plain text at /llms.txt.
On this page
1. Identity: your domain, not an API key
Your sending domain is your account. Generate an Ed25519 keypair, publish the public key in a DNS TXT record, and sign each request with the private key. Whoever controls the domain's DNS controls the account, so there is nothing to copy out of a dashboard and nothing for a human to click. The first correctly signed request creates the account on the free plan.
npm i @sendforai/sdk # coming to npm
import { generateKeypair } from "@sendforai/sdk";
const { privateKeyPem, publicKeyBase64 } = generateKeypair();
// Keep privateKeyPem secret. Publish publicKeyBase64 in DNS.Ask the API for the exact record to publish. This endpoint is public and needs no signature:
GET /v1/domains/example.com/setup?p=<publicKeyBase64>
{
"domain": "example.com",
"identity_record": {
"name": "_sendforai.example.com",
"type": "TXT",
"value": "v=sfa1; k=ed25519; p=<publicKeyBase64>"
},
"next": "Publish that TXT record. Once it resolves, sign requests ..."
}Signed requests carry three headers: SendForAI-Domain, SendForAI-Timestamp and SendForAI-Signature. The SDK adds them for you:
import { SendForAI } from "@sendforai/sdk";
const sfa = new SendForAI({
domain: "example.com",
privateKey: process.env.SENDFORAI_PRIVATE_KEY, // the PEM from generateKeypair()
});2. Verify the domain for sending
Owning the identity record makes you an account. To send mail from the domain, Amazon SES also needs DKIM and MAIL FROM records. POST /v1/domains/{domain}/verify creates the SES identity and returns the records to publish; GET /v1/domains/{domain} shows their status.
const status = await sfa.domains.verify();
// status.ses.dkim_records: three DKIM CNAMEs
// status.ses.mail_from_records: MX and SPF TXT for mail.example.com
// Publish them, then poll:
await sfa.domains.status(); // status.ses.status: "pending" -> "verified"Until SES has verified the domain, a send returns an error whose details list the records still missing.
3. Send a transactional email
POST /v1/emails sends to one address or up to 50. Give it html, text or markdown; Markdown is rendered to HTML with a plain-text part. The from address must be on your domain.
const res = await sfa.emails.send({
from: "hi@example.com",
to: "ada@example.org",
subject: "Your sign-in link",
markdown: "Click to sign in: https://example.com/login?t=...",
idempotency_key: "signin-8f2c",
});
res.status // "sent"
res.cost // { emails: 1, ses_usd: 0.0001, billed_usd: 0, included_remaining: 2999, note: "..." }
res.capacity // { daily_cap: 100, remaining_today: 99, ... }
res.recipients // [{ to: "ada@example.org", status: "accepted" }]
res.receipt // signed record of what was sent and billedOther fields: reply_to, headers, and stream ("transactional" by default; "broadcast" adds one-click unsubscribe headers). Addresses that previously bounced, complained or unsubscribed come back as suppressed and are not sent to.
4. Dry runs, spend caps and retries
dry_run: trueplans the send without sending: it returns the cost, capacity and lint warnings.sfa.emails.plan()is the same call.max_cost_usdrefuses the send with a 402 if it would bill more than the amount you set.idempotency_keymakes retries safe. Sending again with the same key returns the original result and theidempotent-replayed: trueheader; nothing is sent twice.
const plan = await sfa.emails.plan({ from, to, subject, markdown, max_cost_usd: 0.05 });
if (plan.lint.length) console.log(plan.lint); // each warning has a fix5. Contacts
Contacts are unlimited on both plans. POST /v1/contacts upserts one by email, with optional data you can filter broadcasts on and a subscribed flag. GET /v1/contacts pages with limit and cursor.
await sfa.contacts.upsert({ email: "ada@example.org", data: { plan: "pro" } });
await sfa.contacts.unsubscribe("bob@example.org");
const { data, next_cursor } = await sfa.contacts.list({ limit: 100 });Bulk import, POST /v1/contacts/import with up to 10,000 contacts per call, is a Member feature. On Free, contacts are added one at a time.
6. Broadcasts and human approval
A broadcast sends one message to every subscribed contact, or the ones whose data matches a filter. Suppressed addresses are skipped and every email gets a one-click unsubscribe link.
By default a new broadcast doesn't send. It is created as awaiting_approval and the response includes an approval_url. The agent hands that link to a person; when they click approve, it sends.
const b = await sfa.broadcasts.create({
from: "news@example.com",
subject: "What shipped in November",
markdown: "# November\n\nThree things...",
filter: { plan: "pro" }, // optional
max_cost_usd: 1, // optional
});
b.status // "awaiting_approval"
b.approval_url // give this to a human
b.recipient_count
b.cost
// Later:
await sfa.broadcasts.get(b.id); // status: approved | rejected | sending | sent
// progress: { sent, queued, failed, cancelled, resumes_at }
await sfa.broadcasts.cancel(b.id); // also stops a broadcast mid-sendIf your workflow already has its own sign-off, pass require_approval: false and send it with POST /v1/broadcasts/{id}/send. Calling send on a broadcast that is still waiting returns a 409 whose details include the approval link. dry_run works here too.
Sending happens in the background at the SES rate limit, so send returns 202 with status sending and a progress object. On Member, a broadcast bigger than the domain's current daily cap keeps going after the daily reset; the event log shows broadcast.deferred with resumes_at, then broadcast.sent when it finishes. On Free, the whole broadcast has to fit inside today's 100-email cap.
7. Events
Everything that happens is in a pullable event log, so you don't need to host a webhook endpoint. Keep next_cursor and pass it back to get only what's new. Filter with type.
let cursor;
const page = await sfa.events.list({ cursor, limit: 100 });
// page.data: [{ id, type, message_id, broadcast_id, data, created_at }]
cursor = page.next_cursor ?? cursor;Types include email.sent, email.delivery, email.bounce, email.complaint, broadcast.awaiting_approval, broadcast.approved, broadcast.rejected, broadcast.sent and contacts.imported. Hard bounces and complaints add the address to your suppression list automatically.
8. Errors carry the fix
Every error has the same shape: a code, a message saying what happened, and a fix written for the agent to act on. Limits include when they reset and what upgrading costs.
HTTP 429
{
"error": {
"code": "capacity_exceeded",
"message": "This would send 150 emails but only 100 remain in today's cap of 100 for example.com.",
"fix": "The free plan is capped at 100 emails a day. Send fewer now, wait until ..., or upgrade to Member ($20/mo, 200,000 emails included).",
"details": { "requested": 150, "remaining_today": 100, "daily_cap": 100, "resets_at": "...", "plan": "free" }
}
}The SDK throws a SendForAIError with status, code, fix and details.
9. Capacity and warmup
GET /v1/capacity returns today's cap, what's been sent, what remains and when it resets. GET /v1/usage returns the month so far: emails sent, what SES charged, what you're billed, and a projection to the end of the month.
Free domains are capped at 100 emails a day permanently. Member domains start with a daily cap that rises in stages as the domain builds a sending reputation with low bounces and complaints. A domain whose bounce or complaint rate gets too high is paused, and the error says how to clean the list.
To upgrade, call POST /v1/account/upgrade with { "interval": "month" } or "year". It returns a checkout_url for a person to pay, the same hand-off as broadcast approval. When payment completes, an account.upgraded event appears and the caps change. GET /v1/account/billing returns a billing portal link for updating the card, switching to yearly, or cancelling.
10. MCP server
The @sendforai/mcp server (coming to npm) gives Claude, Cursor and other MCP clients the whole API as tools. Add it to your client's MCP config:
{
"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 server without env and call generate_identity; it returns the DNS record to publish and the private key to keep. Tools: generate_identity, get_pricing, get_domain_status, verify_domain, get_capacity, get_usage, plan_email, send_email, upsert_contact, list_contacts, create_broadcast, get_broadcast, send_broadcast, cancel_broadcast, list_events, whoami.
Endpoint reference
| Endpoint | What it does |
|---|---|
GET /v1/pricing | Public pricing as JSON. No signature. |
GET /v1/domains/{domain}/setup?p= | Public. The identity TXT record for a public key. |
GET /v1/domains/{domain} | Identity and SES verification status, with the records to publish. |
POST /v1/domains/{domain}/verify | Create the SES identity; returns DKIM and MAIL FROM records. |
POST /v1/emails | Send, or plan with dry_run. Returns cost, capacity, lint and a receipt. |
GET /v1/capacity | Daily cap, sent today, remaining, warmup stage. |
GET /v1/usage | Month to date: emails, SES cost, billed, projection. |
POST /v1/contacts | Upsert one contact. |
GET /v1/contacts | List contacts (limit, cursor). |
POST /v1/contacts/import | Bulk import up to 10,000. Member only. |
POST /v1/contacts/unsubscribe | Unsubscribe an address. |
POST /v1/broadcasts | Create a broadcast; awaiting_approval by default. |
GET /v1/broadcasts/{id} | Status, recipient count and cost. |
POST /v1/broadcasts/{id}/send | Send an approved broadcast. |
POST /v1/broadcasts/{id}/cancel | Cancel before it sends. |
GET /v1/events | Event log (cursor, limit, type). |
Base URL https://api.sendforai.com. All requests and responses are JSON. Pricing is on the pricing page.