Docs · v1

Quickstart

From API key to an email delivered from your own domain. Every endpoint takes Authorization: Bearer $AGENTSEND_API_KEY. Every refusal is {error: {code, reason, fix}}; apply the fix and retry.

sandbox Delivery is simulated today: every send runs guardrails, events, webhooks, and suppression, but nothing leaves for the internet. Simulator recipients: delivered@, bounced@, and complained@simulator.agentsend.co. Before your domain verifies, you can send from @agentsend.co to simulator addresses only.

01Get a key

Sign in, open API keys, create one. Use sending_access (optionally scoped to one domain) for agents that only send.

export AGENTSEND_URL=https://www.agentsend.co
export AGENTSEND_API_KEY=as_...

02Add and verify a domain

curl -X POST $AGENTSEND_URL/api/v1/domains \
  -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "mail.acme.com"}'
# → records: MX + SPF TXT on send.mail.acme.com, DKIM CNAME, DMARC TXT

curl -X POST $AGENTSEND_URL/api/v1/domains/$DOMAIN_ID/verify \
  -H "Authorization: Bearer $AGENTSEND_API_KEY"
# → {"status": "pending", "missing_records": [{"type": "CNAME", "name": "…", "value": "…",
#     "problem": "missing", "found": []}], "fix": "Publish each record…"}

Verification queries live DNS and names each record that is missing or has the wrong value. Call it again after publishing until status is verified.

03Send

curl -X POST $AGENTSEND_URL/api/v1/emails \
  -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-4821-welcome" \
  -d '{"from": "Acme <hello@mail.acme.com>", "to": ["dana@example.com"],
       "subject": "Welcome to Acme", "html": "<p>Thanks for signing up.</p>",
       "tags": [{"name": "category", "value": "welcome"}]}'

Add ?dry_run=true to run every guardrail and get errors and warnings without sending. Add scheduled_at to send later; cancel with POST /api/v1/emails/{id}/cancel.

04Broadcast to an audience

curl -X POST $AGENTSEND_URL/api/v1/audiences -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" -d '{"name": "Customers"}'
curl -X POST $AGENTSEND_URL/api/v1/audiences/$AUDIENCE_ID/contacts \
  -H "Authorization: Bearer $AGENTSEND_API_KEY" -H "Content-Type: application/json" \
  -d '{"email": "dana@example.com", "first_name": "Dana"}'
curl -X POST $AGENTSEND_URL/api/v1/broadcasts -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"audience_id": "'$AUDIENCE_ID'", "from": "Acme <news@mail.acme.com>",
       "subject": "October update",
       "html": "<p>Hi {{first_name}}</p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a>"}'
curl -X POST $AGENTSEND_URL/api/v1/broadcasts/$BROADCAST_ID/send \
  -H "Authorization: Bearer $AGENTSEND_API_KEY"

Broadcasts require {{unsubscribe_url}} and go out with List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click. Unsubscribes suppress the address for good.

05Events and webhooks

curl "$AGENTSEND_URL/api/v1/events?since=0&type=email.bounced,guardrail.blocked" \
  -H "Authorization: Bearer $AGENTSEND_API_KEY"
curl -X POST $AGENTSEND_URL/api/v1/webhooks -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "https://api.acme.com/hooks/agentsend", "events": ["email.bounced"]}'

Events: email.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked, contact.unsubscribed, guardrail.blocked. Open and click tracking are not emitted yet. Deliveries follow Standard Webhooks: verify webhook-signature (v1,<base64>) as HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} keyed with the base64-decoded part of your whsec_ secret.

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

function verify(secret: string, id: string, ts: string, body: string, header: string) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = "v1," + createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  return fresh && header.split(" ").some((sig) =>
    sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}

06Budgets

curl -X PUT $AGENTSEND_URL/api/v1/budget -H "Authorization: Bearer $AGENTSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"per_minute": 30, "per_day": 2000, "max_recipients_per_send": 10}'

Applies to the calling key (or api_key_id, or "account": true for the account default). Without one: 60/min, 1,000/day, 50 recipients per send. Plan quotas apply on top.

07MCP

Streamable HTTP at https://www.agentsend.co/api/mcp. Tools: send_email, send_batch, get_email, lint_email, create_domain, verify_domain, list_domains, create_audience, add_contact, create_broadcast, send_broadcast, list_events, set_budget.

claude mcp add --transport http agentsend https://www.agentsend.co/api/mcp \
  --header "Authorization: Bearer $AGENTSEND_API_KEY"
{
  "mcpServers": {
    "agentsend": {
      "type": "http",
      "url": "https://www.agentsend.co/api/mcp",
      "headers": { "Authorization": "Bearer as_..." }
    }
  }
}

08Migrating from Resend

The shapes match where it matters: POST /emails with from, to, cc, bcc, reply_to, subject, html, text, headers, tags, attachments, scheduled_at, plus Idempotency-Key, batch sends of 100, audiences, contacts, and broadcasts. Point your base URL at https://www.agentsend.co/api/v1, swap the key, and re-add your domain (records differ). Differences: blocked sends return {error: {code, reason, fix}} instead of sending; broadcasts substitute {{first_name}}-style variables; attachments take base64 content (no remote path yet).

09Endpoints

POST/api/v1/emailsSend. Idempotency-Key header. ?dry_run=true lints only.
POST/api/v1/emails/batchUp to 100 emails; all or nothing.
GET/api/v1/emails/{id}Status: scheduled, delivered, bounced, complained, blocked…
PATCH/api/v1/emails/{id}Reschedule {scheduled_at}
POST/api/v1/emails/{id}/cancelCancel a scheduled email
POST/api/v1/domains{name} → DNS records
GET/api/v1/domainsList domains
GET/api/v1/domains/{id}Domain + record status
POST/api/v1/domains/{id}/verifyLive DNS check → missing_records
DELETE/api/v1/domains/{id}Remove a domain
POST/api/v1/audiences{name}
GET/api/v1/audiencesList audiences
DELETE/api/v1/audiences/{id}Delete with its contacts
POST/api/v1/audiences/{id}/contacts{email, first_name?, last_name?}
GET/api/v1/audiences/{id}/contactsList contacts
PATCH/api/v1/audiences/{id}/contacts/{contact_id}{first_name?, last_name?, unsubscribed?}
DELETE/api/v1/audiences/{id}/contacts/{contact_id}Remove a contact
POST/api/v1/broadcasts{audience_id, from, subject, html|text} → draft
GET/api/v1/broadcasts/{id}Broadcast status
POST/api/v1/broadcasts/{id}/sendSend now or {scheduled_at}
POST/api/v1/webhooks{endpoint, events?} → signing secret (once)
GET/api/v1/webhooksList webhooks
DELETE/api/v1/webhooks/{id}Remove a webhook
POST/api/v1/webhooks/{id}/testSigned webhook.test delivery
GET/api/v1/events?since=<cursor>&type=a,b → {data, next_cursor}
PUT/api/v1/budget{per_minute, per_day, max_recipients_per_send, api_key_id?|account?}

10Error codes

401unauthorizedMissing, unknown, or revoked API key.
403restricted_api_keysending_access keys can only send email.
403unverified_from_domainfrom is not on a verified domain.
403from_domain_not_allowedDomain-scoped key used another domain.
422empty_body / image_only_emailNo real content.
422unrendered_template{{var}} or {var} left in subject or body.
422missing_unsubscribe_linkBroadcast without {{unsubscribe_url}}.
422recipient_suppressedHard bounce, complaint, or unsubscribe on file.
422too_many_recipientsOver max_recipients_per_send.
413payload_too_largeOver 40 MB.
409duplicate_emailSame recipients + subject + body within 10 minutes.
423loop_detected / api_key_paused3 duplicates in 10 minutes pause the key.
429budget_exceeded_minute / _dayPer-key or account budget reached.
429plan_quota_exceeded / plan_daily_quota_exceededPlan volume reached; upgrade.
403plan_domain_limit / plan_contact_limitPlan limit reached; upgrade.
422batch_blockederror.errors lists each failing index.