Early access · Updated November 5, 2026

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
  2. Verify the domain for sending
  3. Send a transactional email
  4. Dry runs, spend caps and retries
  5. Contacts
  6. Broadcasts and human approval
  7. Events
  8. Errors carry the fix
  9. Capacity and warmup
  10. MCP server
  11. Endpoint reference

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 billed

Other 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: true plans the send without sending: it returns the cost, capacity and lint warnings. sfa.emails.plan() is the same call.
  • max_cost_usd refuses the send with a 402 if it would bill more than the amount you set.
  • idempotency_key makes retries safe. Sending again with the same key returns the original result and the idempotent-replayed: true header; 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 fix

5. 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-send

If 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

EndpointWhat it does
GET /v1/pricingPublic 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}/verifyCreate the SES identity; returns DKIM and MAIL FROM records.
POST /v1/emailsSend, or plan with dry_run. Returns cost, capacity, lint and a receipt.
GET /v1/capacityDaily cap, sent today, remaining, warmup stage.
GET /v1/usageMonth to date: emails, SES cost, billed, projection.
POST /v1/contactsUpsert one contact.
GET /v1/contactsList contacts (limit, cursor).
POST /v1/contacts/importBulk import up to 10,000. Member only.
POST /v1/contacts/unsubscribeUnsubscribe an address.
POST /v1/broadcastsCreate a broadcast; awaiting_approval by default.
GET /v1/broadcasts/{id}Status, recipient count and cost.
POST /v1/broadcasts/{id}/sendSend an approved broadcast.
POST /v1/broadcasts/{id}/cancelCancel before it sends.
GET /v1/eventsEvent log (cursor, limit, type).

Base URL https://api.sendforai.com. All requests and responses are JSON. Pricing is on the pricing page.