email59

Docs Beta

email59 is in beta: the API is at version 0.4, so names and limits may still change before 1.0. Sign-up is self-service and free.

email59 is an email router. You call one JSON API and we route the message to an established sending provider, so you get email in your app before you own a domain: no DNS records, no account review, no SMTP server. Bots and agents sign themselves up with an email code, and the MCP server is built in. Base URL: https://email59.com/v1.

Authenticate with Authorization: Bearer <key>, where the key is an API key (e59_live_...) or a session key from email sign-in (e59s_...). Agents can use the MCP server instead of the REST calls. One email = one recipient. Every account gets free emails every day (200 a day; new accounts start at 50); paid credits last until used (Allowance, pricing). Mail is sent from <name>@email59.com: there is no domain to set up. No subscriptions.

Interactive reference (OpenAPI): /v1/docs.

Sending rules

No email sent through email59 is marketing, and email from a kid-safe address is kid-safe too. The rules apply to every account, through every way of sending: /v1/send, the MCP send_email tool, the Supabase hook and the mailbox.

  • Sending is never marketing: marketing wording (discount codes, "buy now", "limited time offer") is refused (422 content_not_allowed, reason marketing), and campaigns, category: marketing and opt-ins answer 403 marketing_not_allowed.
  • From a kid-safe address (one of your mailboxes opened as kid-safe), an email with bad language or sexual content is refused (reason not_kid_safe), and so is text not in English (reason not_english). Mailbox compose and reply check it for that mailbox; /v1/send checks it when from is your own kid-safe mailbox. Other addresses send their text as written.
  • Mail that reaches a kid-safe address is cleaned on the way in: bad language, sexual content and text in other languages are removed, and attachments are checked before they can be opened (how).
  • Every email is read before it goes out (subject, body and the names, types and sizes of attachments). Sexual content, phishing, pure marketing or bulk mail without an unsubscribe link is not sent (422 content_refused with a one-line reason), and sends must be a few seconds apart (429 sending_too_fast). Either one pauses the account's sending for 3 minutes (429 sending_paused).
  • Every email sent or received is read by an automated AI check. When a sent email breaks these rules, the account's sending is put on hold (403 sending_on_hold) until support has looked.
  • Mail from outside accounts you add in the email59 app is filtered the same way. Mail to normal addresses is stored as sent.

A refused send uses none of your emails. The answer names what was found, so you can fix the text and send again:

HTTP 422
{"detail": {"error": "content_not_allowed", "reasons": ["marketing"],
            "words": 0, "passages": 0, "marketing": ["buy now"], "message": "..."}}

Sign up with an email code

Bots, scripts and agents sign up or sign in with two calls to POST /v1/auth. The first emails a 6-digit code to the address. The second sends the code back and returns a session key. A new address is signed up on the spot with 200 emails a day free; an existing one is signed in.

# 1. Ask for a code
curl -s https://email59.com/v1/auth -H 'Content-Type: application/json'   -d '{"email":"you@yourapp.com"}'
# -> {"otp_sent": true, "expires_in_seconds": 600, "next": "Call auth again with the same email and the 6-digit otp..."}

# 2. Send the code back
curl -s https://email59.com/v1/auth -H 'Content-Type: application/json'   -d '{"email":"you@yourapp.com","otp":"123456"}'
# -> {"session_key": "e59s_...", "token_type": "Bearer", "expires_at": "...", "new_account": true, "account": {...}}

Send the session key as Authorization: Bearer e59s_... on every call that takes an API key. It lasts 30 days. Then:

POST   /v1/keys     {"label":"server"}             -> {"api_key": "e59_live_..."} a long-lived key, shown once (10 max)
GET    /v1/keys                                    -> your keys (labels and dates, never the key itself)
DELETE /v1/keys/{id}                               revoke a key
GET    /v1/invites/{code}                          -> what an invite adds and whether it can still be accepted (no key)
POST   /v1/invites/accept {"code":"E59-..."}        -> {"added_credits": ...} one invite per account
POST   /v1/auth     {"api_key":"e59_live_..."}       swap an API key for a session key
POST   /v1/auth/logout                             end the session key in the Authorization header
RuleValue
Code lifetime10 minutes, single use. 5 wrong tries end the code.
Codes per address3 per 15 minutes.
New addressesMust be a real mailbox: disposable addresses and domains without MX are refused.
AccountsOne per email, shared with the account page sign-in and /v1/signup.

Sign up with a key

POST /v1/signup with your email. Returns an API key (shown once). Every account gets free emails every day: 50 a day to start, 200 once you've signed in on the account page, bought any credits, or used it for a week. People can sign in on the account page with osec.one instead and create keys there.

curl -s https://email59.com/v1/signup -H 'Content-Type: application/json' \
  -d '{"email":"you@yourapp.com","name":"Ada","company":"Yourapp"}'
FieldNotes
emailYour login address. One account per address.
name, companyOptional.

MCP server

email59 is also an MCP server for AI agents: https://email59.com/v1/mcp (JSON-RPC 2.0 over HTTP POST). Tools in OpenAI / Gemini function-calling format: /v1/mcp/schema.

Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05. Every tool in tools/list has a title and annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), so a client can ask before tools that send mail, spend money or remove a block.

No sign-in at all: https://email59.com/v1/mcp/public answers the same methods with only check_email and get_pricing, at the no-key limits. It ignores keys; use /v1/mcp to send.

OAuth: apps such as Claude and ChatGPT can connect with OAuth 2.1 instead of the auth tool. Without a token, account tools answer HTTP 401 with WWW-Authenticate pointing at /.well-known/oauth-protected-resource; the app registers itself, you sign in on an email59 page with the emailed code, and it gets a session key (PKCE S256, refresh tokens). For directory apps use https://email59.com/v1/mcp/oauth: the same tools, but every request there needs a token, so the app starts OAuth right away.

Sign-in works like the REST API: call the auth tool with email, then again with email and otp. Send the returned session_key as Authorization: Bearer <session_key> on later requests. If your MCP client sets headers in its config, an API key works there too.

{
  "mcpServers": {
    "email59": {
      "type": "http",
      "url": "https://email59.com/v1/mcp",
      "headers": { "Authorization": "Bearer e59_live_..." }
    }
  }
}
curl -s https://email59.com/v1/mcp -H 'Content-Type: application/json'   -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"auth","arguments":{"email":"you@yourapp.com"}}}'
ToolDoes
authEmail code sign-up and sign-in, or api_key to session key. No key needed.
get_pricingFree emails, credits and packs. No key needed.
check_emailDeep check of one address with a score (same as GET /v1/check). No key needed.
check_listCheck up to 1,000 addresses (or a CSV's text) at once: valid, risky, role, disposable, no_mx or invalid each (same as POST /v1/check/bulk).
get_account, update_accountToday's numbers and credits; set name and company.
send_emailOne transactional email (same as POST /v1/send, sending rules included).
send_campaignClosed: answers 403 marketing_not_allowed (see Campaigns).
validate_emailVerdict for one address.
block, unblock, list_blocklist, add_unsubscribeRecipient lists.
buy_pack, order_statusA Checkout link for a person to pay, and its status.
create_api_key, list_api_keysLong-lived keys for servers. mode: "test" makes a test key.
list_test_messagesWhat test keys sent (stored, never delivered); pass id for one email with its body.
verify_start, verify_checkEmail a one-time code and check it (same as /v1/verify/*).

Tool errors come back as isError: true with the same error and status as the REST API (see Errors).

Send

POST /v1/send sends one email to one recipient, for transactional mail: receipts, codes, alerts and notices. Marketing mail (newsletters, offers) is closed, and every email must keep to the sending rules. Every email goes out as HTML in a template with your brand, plus a text part: send text, html or both. One email to many people at once: campaigns.

curl -s https://email59.com/v1/send \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"from":"hello","to":"user@gmail.com","subject":"Your report",
       "html":"<p>Ready.</p>","text":"Ready."}'
FieldNotes
fromOptional. The name before the @; the domain is always email59.com. Any other domain is refused (from_must_use_email59_com), and so is a name that is somebody else's mailbox (403 from_is_a_mailbox) or one that reads as email59 itself, like admin, support or billing (403 from_is_reserved). Letters and digits, with single dots, dashes, underscores or a plus between them.
toOne address.
subject, html, textAt least one of html or text. Text alone becomes HTML (paragraphs and links); HTML alone gets a text part made from it. A full HTML document keeps only what is inside its <body>.
categorytransactional (default). marketing is closed and answers 403 marketing_not_allowed; the notes that follow are kept for reference. Marketing always carries the unsubscribe link and is refused for role addresses like info@ and for people who unsubscribed from you (or from list_id): 422 recipient_rejected with verdict role or unsubscribed.
list_idMarketing only (closed). Your name for the audience, e.g. news; an unsubscribe then stops that list.
template, heading, preheader, button_text, button_url, code, brandOptional. The layout and look: see Templates and brand.
reply_toOptional. One address of your own where answers should go, like help@yourapp.example. See Replies.

Replies

Mail goes out from an address at email59.com, and a reply to that address reaches nobody. For receipts, billing notices and anything else people answer, give reply_to: it becomes the email's Reply-To header, so pressing Reply writes to your inbox.

curl -s https://email59.com/v1/send \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"from":"acme-billing","to":"user@gmail.com","reply_to":"help@yourapp.example",
       "subject":"Your invoice","text":"Invoice INV-1042 is ready. Questions? Just reply."}'
# 201 {"id":"...","status":"sent","reply_to":"help@yourapp.example",...}
  • One plain address, on a domain that takes mail. A list, a name in angle brackets or a throwaway inbox is refused (400 invalid_reply_to, 422 reply_to_rejected with a verdict), and nothing is counted against your day.
  • Not an address at email59.com (400 reply_to_must_not_use_email59_com).
  • The sender stays @email59.com; reply_to changes where answers go and nothing else.
  • An email with reply_to leaves out the "replies are not received" line, since for that email it wouldn't be true. The unsubscribe link is added as usual.
  • Not available on campaigns yet: a campaign with reply_to is refused (400 reply_to_not_available_for_campaigns) and nothing is sent.
  • Test keys check it the same way and show it on the stored email.

Without reply_to, every email says it is a system generated email and that replies are not received. Unless your content already has an unsubscribe link, email59 adds one: it names the person and the sender, and one press stops your mail to that address (see Unsubscribe). Transactional mail still goes to people who unsubscribed; marketing and campaigns skip them. Blocked and invalid addresses are refused.

Templates and brand

Every email is sent as HTML in a template: a header band with your name or logo, your content in a column that fits a phone, and a footer band with your address line, the system line and the unsubscribe link. You never pick "HTML or text": send what you have and email59 builds both parts.

curl -s https://email59.com/v1/send   -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json'   -d '{"from":"orders","to":"user@gmail.com","subject":"Payment received",
       "template":"notice","heading":"Thanks, your order is paid",
       "text":"Order 1042 is paid and on its way.",
       "button_text":"See the order","button_url":"https://yourshop.example/orders/1042"}'
templateFor
basicThe default: header band, your content, footer band.
codeOne-time codes. Give code; it is shown large and leads the text part. No unsubscribe link.
noticeOne message and one button: receipts, alerts, welcome emails. Give heading, button_text and button_url.
reportA wider column with smaller type for tables: daily summaries and reports.
letterA personal email: a thin line in your colour instead of the band, so it reads like a letter.

preheader is the short preview inboxes show after the subject. heading is a title above the content.

Your brand

Set it once on the account and every email uses it; a brand object on one email changes only that email.

curl -s -X PATCH https://email59.com/v1/account   -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json'   -d '{"brand":{"name":"Acme Shop","color":"#0a7d3b","logo_url":"https://yourshop.example/logo.png",
                "url":"https://yourshop.example","address":"Acme Shop, 12 Main St, Springfield"}}'
  • name shows in the header band (and as the logo's alt text); color is the band, #rgb or #rrggbb; the band's text turns dark on a light colour.
  • logo_url and url must be https:// addresses. The logo shows 28 px high.
  • address is the first footer line, e.g. your company and city.
  • Without a brand, the band shows the name before the @ of your from address in grey. Send {"brand":{"name":""}} to clear it.
  • MCP: update_account takes brand; send_email takes the same fields as /v1/send.

Test keys

A test key (e59_test_...) is for staging servers and CI. Everything it sends is checked exactly like a real send, stored, and never delivered. It uses none of your allowance. Give production a live key and everything else a test key, and a staging server can't email a real person through email59.

# make one (with a live key or a session key; or pick "Test" on the account page)
curl -s https://email59.com/v1/keys \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"label":"ci","mode":"test"}'
# -> {"id":"...","label":"ci","mode":"test","api_key":"e59_test_..."}

# send with it: same call, same checks, nothing goes out
curl -s https://email59.com/v1/send \
  -H 'Authorization: Bearer e59_test_...' -H 'Content-Type: application/json' \
  -d '{"from":"hello","to":"user@gmail.com","subject":"Welcome","text":"Thanks for signing up."}'
# -> 201 {"id":"test_...","status":"test","test":true,"delivered":false, ...}

# read what was "sent"
curl -s 'https://email59.com/v1/test/messages?to=user@gmail.com' -H 'Authorization: Bearer e59_test_...'
curl -s https://email59.com/v1/test/messages/test_... -H 'Authorization: Bearer e59_test_...'
# -> {"id","kind","from","to","subject","html","text","created_at","delivered":false}
With a test keyWhat happens
POST /v1/send201 with "status": "test" (a live key answers "sent"). Bad addresses, blocked recipients, an empty body or a wrong from are refused with the same errors as a live send. The stored email has the system line and unsubscribe link it would have gone out with.
POST /v1/campaignsRecipients are sorted into accepted and rejected as usual; one stored email holds the whole campaign with its recipient_list.
POST /v1/verify/startThe code email is stored, not delivered, and the answer carries test_message_id. Read the code from that stored email's subject and pass it to /v1/verify/check, so a test can walk the whole sign-up.
GET /v1/test/messages, /{id}, DELETE /v1/test/messagesList (newest first, to and limit optional), read one, clear. Any key of the account can read them, and they show on the account page.
Making keys, buying, changing the account, blocklist or unsubscribe lists, swapping the key for a session key403 test_key_not_allowed. A key that lives in CI shouldn't be able to make a live one.

The newest 100 test emails per account are kept, for 7 days; bodies are stored up to 100,000 characters each. A test key takes 2,000 emails in 24 hours (429 test_limit_reached after that). A test key doesn't tell you whether a real mailbox would accept the email or where it would land; for that, send one real email to an address you own. In your own code, the dev mail fence adds a second fence: one send function that sends nothing unless an environment is told to.

Supabase auth emails (Send Email Hook)

Supabase's built-in sender only delivers 2 auth emails an hour, and only to your team's addresses. Point Supabase's Send Email Hook at email59 and every sign-up confirmation, magic link, password reset, invite, email change and reauthentication code goes out through us instead. No Edge Function to write, no SMTP server.

  1. In Supabase: Authentication → Hooks → Send Email Hook, type HTTPS. Click Generate secret and copy it (v1,whsec_...). Leave the URL for step 3.
  2. Register the hook with your email59 key. supabase_url is your project URL (Project Settings → API); the links in the emails go to its /auth/v1/verify.
    curl -s https://email59.com/v1/hooks/supabase   -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json'   -d '{"secret":"v1,whsec_...","supabase_url":"https://abcdefghij.supabase.co",
           "app_name":"Acme","from":"auth"}'
    The answer has a url like https://email59.com/v1/hooks/supabase/sbh_....
  3. Paste that URL into the Supabase hook, save, and sign up with a test address. The email arrives from auth@email59.com with your app's name in the subject.
What we do with each callDetails
Check the signatureStandard Webhooks: webhook-id, webhook-timestamp, webhook-signature (HMAC-SHA256 with your secret). Calls older than 5 minutes or with a wrong signature get 401 and nothing is sent.
Answer within 5 secondsSupabase waits 5 s for a hook. We give the send 4.5 s; a slow gateway gets 504 instead of a timeout, so Supabase shows your user a clear error.
Build the emailOne email per call with the code (token) and the verify link (token_hash, email_action_type, redirect_to). Secure email change sends two: one to the new address, one to the current one. Reauthentication sends the code only.
Reply200 {} on success. Errors use the shape Supabase expects: {"error":{"http_code":429,"message":"email59: ..."}}.

Auth emails have no unsubscribe link (people can't opt out of their own password reset); they carry the usual system line. Each one counts as one email. GET /v1/hooks/supabase lists your hooks with a call count; DELETE /v1/hooks/supabase/{id} turns one off (the URL stops working at once). If you'd rather keep the code in your project, the Edge Function version calls /v1/send instead; a ready-to-deploy copy is on GitHub: icedsg/supabase-email59-send-hook (MIT).

Verify (email codes)

Two calls to prove someone owns an address: start emails a one-time code, check says whether what they typed is right. email59 makes the code, sends it, limits resends, counts wrong tries and expires it, so your app stores nothing. Each code sent counts as one email of your allowance; checks are free. Try it on the Verify test page.

curl -s https://email59.com/v1/verify/start \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"to":"ada@example.org","app_name":"Acme"}'
# 201 {"id":"vrf_3f9c...","to":"ada@example.org","status":"pending","expires_at":"...","expires_in_seconds":600,
#      "code_length":6,"max_attempts":5,"resend_after_seconds":30,"sent_today":1,"remaining_today":199}

curl -s https://email59.com/v1/verify/check \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"to":"ada@example.org","code":"482913"}'
# 200 {"valid":true,"status":"approved","id":"vrf_3f9c...","attempts":0,"attempts_left":0,"checked_at":"...",...}
start fieldNotes
toThe address to verify. Invalid, throwaway and blocked addresses get 422 recipient_rejected and cost nothing.
app_nameOptional, up to 60 characters. The subject reads 482913 is your Acme verification code.
fromOptional name before @email59.com. Default verify.
code_length4 to 10 digits. Default 6.
expires_inSeconds, 60 to 3600. Default 600 (10 minutes).

check takes code and either to (the newest code sent there from your account) or the id from start. Spaces and dashes in the code are ignored. It always answers 200; read valid, and status for why:

statusMeaning
approvedRight code (valid: true). It won't work a second time.
wrong_codeNot the code. attempts_left says how many tries remain.
too_many_attemptsFive wrong codes locked it. Send a new one.
expiredPast expires_at. Send a new one.
already_usedThis code was approved before.
replacedA newer code went to the same address (checked by id); only the newest works.
not_foundNo code from your account for this address or id (codes are deleted after 30 days).

Limits per address: one code every 30 seconds (429 too_soon with retry_after_seconds) and 5 an hour (429 too_many_codes). GET /v1/verify/{id} shows where a verification stands, never the code. MCP: verify_start and verify_check. The email carries the system line and no unsubscribe link (a code someone asked for isn't marketing).

Next.js and Supabase example

Two route handlers in a Next.js App Router project. Your email59 key stays on the server (EMAIL59_KEY in .env.local); the browser only posts the email and the code.

// app/api/verify/start/route.ts
export async function POST(req: Request) {
  const { email } = await req.json();
  const r = await fetch("https://email59.com/v1/verify/start", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.EMAIL59_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ to: email, app_name: "Acme" }),
  });
  const out = await r.json();
  if (!r.ok) {
    // 422 recipient_rejected, 429 too_soon / too_many_codes: show the person a short message
    return Response.json({ error: out.detail?.error ?? out.detail }, { status: r.status });
  }
  return Response.json({ sent: true, expiresInSeconds: out.expires_in_seconds });
}
// app/api/verify/check/route.ts
export async function POST(req: Request) {
  const { email, code } = await req.json();
  const r = await fetch("https://email59.com/v1/verify/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.EMAIL59_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ to: email, code }),
  });
  const out = await r.json();
  if (!out.valid) return Response.json({ ok: false, reason: out.status }, { status: 400 });
  // The person owns this address. Mark it verified in your own users table, start your session, etc.
  return Response.json({ ok: true });
}

With Supabase, sign the person in once the code is approved: the check route makes the user (if new) with the service role key and returns a one-time token hash, and the browser turns it into a normal Supabase session.

// app/api/verify/check/route.ts, Supabase version (after out.valid is true)
import { createClient } from "@supabase/supabase-js";
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

await admin.auth.admin.createUser({ email, email_confirm: true });   // fails harmlessly if the user exists
const { data, error } = await admin.auth.admin.generateLink({ type: "magiclink", email });
if (error) return Response.json({ ok: false }, { status: 500 });
return Response.json({ ok: true, tokenHash: data.properties.hashed_token });

// In the browser (supabase = createClient(url, anonKey)):
const { tokenHash } = await (await fetch("/api/verify/check", { method: "POST", body: JSON.stringify({ email, code }) })).json();
await supabase.auth.verifyOtp({ token_hash: tokenHash, type: "email" });   // now signed in

If you already use Supabase Auth's own emails, the Send Email Hook is simpler: Supabase keeps making the codes and email59 only delivers them. The Verify API fits when you run your own sign-up, confirm an address outside Supabase Auth, or don't use Supabase at all.

Campaigns (marketing) Closed

Closed: email59 does not carry marketing email. POST /v1/campaigns and the MCP send_campaign tool answer 403 marketing_not_allowed. The text below is kept for reference.

POST /v1/campaigns sends one marketing email to up to 1,000 recipients through our campaign provider, which adds the unsubscribe link and processes opt-outs. One email of today's allowance per accepted recipient. It goes out in the same templates and brand as /v1/send (template basic, notice or letter).

curl -s https://email59.com/v1/campaigns \
  -H 'Authorization: Bearer e59_live_...' -H 'Content-Type: application/json' \
  -d '{"from":"news","subject":"New in May","html":"<p>Hello</p>",
       "list_id":"news","recipients":["user@gmail.com","ada@example.org"]}'
FieldNotes
fromOptional, as for /v1/send: the name before @email59.com.
recipients1 to 1,000 addresses. Duplicates are merged.
list_idYour name for the audience. Opt-outs for this list (or for all lists) are skipped.

The response lists accepted and rejected (with a verdict for each address you did not send to). Invalid, disposable, role (info@, support@), blocked and unsubscribed addresses cost nothing.

Validate

POST /v1/validate checks an address without sending. Returns verdict: ok, invalid, no_mx, disposable or role.

curl -s https://email59.com/v1/validate -H 'Authorization: Bearer e59_live_...' \
  -H 'Content-Type: application/json' -d '{"email":"info@acme.com"}'
# {"verdict":"role", ...}

Deep check (free, no key)

GET /v1/check?email=... (or POST /v1/check with {"email":"..."}) scans one address: format, the provider's username rules, disposable and role addresses, and the domain's MX, SPF, DMARC, DKIM (common selectors), MTA-STS, TLS-RPT and BIMI records. Gmail, Outlook, Yahoo, iCloud and other big providers skip DNS. No email is sent and no mailbox (SMTP) test is made, so the score (0 to 99) is an estimate. Try it on the email check page.

Free: 100 checks a day per IP without a key, 1,000 a day per account with Authorization: Bearer (an API or session key). Over the limit: 429 daily_check_limit.

curl -s 'https://email59.com/v1/check?email=sam@acme.com'
# {"email":"sam@acme.com","domain":"acme.com","provider":"Google Workspace","popular_provider":false,
#  "score":95,"verdict":"likely_valid","summary":"The domain is set up to receive email; ...",
#  "checks":[{"name":"format","status":"pass",...},{"name":"mx","status":"pass","records":["1 smtp.google.com"]},
#            {"name":"spf",...},{"name":"dmarc",...,"policy":"reject"},{"name":"dkim",...}, ...],
#  "note":"No email was sent and no mailbox (SMTP) test was made. ...",
#  "quota":{"limit_per_day":100,"used_today":1,"resets_at":"..."}}

Verdicts: likely_valid (80+), risky (40 to 79, also throwaway services), likely_invalid (under 40: bad format, no domain, no or null MX) and unknown (DNS timed out). Each check has a status: pass, warn, fail, info or skip.

List check

POST /v1/check/bulk checks a whole list before you send to it: up to 1,000 different addresses a call, as emails or as the text of a CSV in csv (the column headed email is used; with no such header, every cell with an @). Each address gets one status and a reason: format, the provider's username rules, disposable and role addresses, and one MX lookup per domain. Lighter than the deep check: no SPF, DMARC or DKIM. No email is sent and no mailbox is tested. Duplicates are checked once. People can do the same on the account page with a CSV file.

Needs Authorization: Bearer (an API or session key). Each different address counts once against the account's daily limit: 2,000 a day, 20,000 once the account has bought credits. A list that doesn't fit what is left today is refused whole (429 daily_list_check_limit with remaining_today), so nothing is half-checked. Bigger lists go in several calls. MCP tool: check_list.

curl -s https://email59.com/v1/check/bulk -H "Authorization: Bearer $EMAIL59_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"emails":["sam@acme.com","info@acme.com","x@mailinator.com","bad@nomail.example"]}'
# {"results":[{"email":"sam@acme.com","status":"valid","reason":"mx_found","domain":"acme.com","detail":"..."},
#             {"email":"info@acme.com","status":"role","reason":"role",...},
#             {"email":"x@mailinator.com","status":"disposable",...},
#             {"email":"bad@nomail.example","status":"no_mx","reason":"no_mail_server",...}],
#  "counts":{"valid":1,"risky":0,"role":1,"disposable":1,"no_mx":1,"invalid":0},
#  "checked":4,"duplicates_skipped":0,"domains_looked_up":2,
#  "quota":{"limit_per_day":2000,"used_today":4,"remaining_today":1996,"resets_at":"..."}}
statusreasonMeaning
validmx_found, popular_providerThe domain takes mail (or it is Gmail, Outlook, ... with a username that fits its rules). The mailbox itself is not tested.
riskya_record_only, dns_timeoutNo MX record (mail would go to the A record), or DNS did not answer in time.
roleroleA shared inbox such as info@ or support@, not a person.
disposabledisposableA throwaway email service.
no_mxno_mail_server, null_mxThe domain has no mail server, or says it takes no email.
invalidinvalid_format, provider_rules, no_domainNot an address, a username the provider doesn't allow, or a domain that doesn't exist.

Blocklist

Your own list of addresses and domains. Blocked recipients are refused before they count against your day.

POST   /v1/blocklist   {"kind":"email","value":"bad@example.org","reason":"bounced"}
POST   /v1/blocklist   {"kind":"domain","value":"spam.example"}
GET    /v1/blocklist
DELETE /v1/blocklist?kind=email&value=bad@example.org

Unsubscribe lists

Transactional email carries its own unsubscribe link. It opens a page at /v1/unsubscribe/{token} that names the person (for example user@gmail.com) and the sender, and asks for one press. Pressing it stops campaigns from that sender to that address. Opening the link alone changes nothing, so link scanners can't unsubscribe anyone.

Campaign mail carries the campaign provider's unsubscribe link. Record opt-outs here so later campaigns skip them, for example when a person asks you directly or when you sync opt-outs from another tool.

POST /v1/unsubscribes   {"email":"user@gmail.com","list_id":"news"}   // empty list_id = all lists
GET  /v1/unsubscribes?email=user@gmail.com

Marketing email sent with /v1/send also carries List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers, so Gmail, Yahoo and other mail apps can show their own unsubscribe button. Their one-click request unsubscribes the address straight away.

Allowance

200 emails a day free, renewed every day (00:00 UTC). Unused free emails don't carry over. New accounts start at 50 free emails a day. You get the full 200 with one sign-in on the account page, any credit purchase, or after a week with sends on three days. Free emails come from a shared monthly pool; if it ever runs out, free sending pauses until the next month and credits keep working. A paused pool answers 429 free_emails_paused. Each $1 buys 3,636 email credits, plus a purchase fee of 5% of the pack, at least $0.50, at checkout. Credits don't renew and don't expire: they last until used. Each day, free emails are used first, then credits. Check the numbers with GET /v1/account (daily_limit, sent_today, remaining_today, credits_remaining). Packs and prices: /v1/pricing.

Account console

People sign in on the account page with osec.one (email code, Google or Apple). The console sends the osec session as Authorization: Bearer. These calls use the same session:

GET    /v1/web/account                 -> daily_limit, sent_today, credits_remaining, name, company
PATCH  /v1/web/account  {name, company}
GET    /v1/web/keys                    -> keys (labels and dates, never the key itself)
POST   /v1/web/keys     {label, mode}  -> {id, api_key}   (api_key shown once; up to 10 keys; mode live or test)
DELETE /v1/web/keys/{id}               -> revokes the key at once
GET    /v1/web/test/messages           -> what test keys sent (also /{id}, and DELETE to clear)
POST   /v1/web/check/bulk {emails | csv} -> a list check (same answer as /v1/check/bulk)

Profile photo: PUT /v1/account/photo with the picture (JPEG, PNG, WebP or GIF, up to 8 MB) as the request body and its Content-Type; it is made square, at most 256 px, and GET /v1/account shows photo_url. DELETE /v1/account/photo removes it. Anyone can look a photo up by address without the address being shown: GET /v1/avatar/{sha256 of the lowercase address, hex} answers 302 to the photo, or 404.

Bots don't need the console: email sign-in (or POST /v1/signup) and the bearer key do everything above with /v1/account and /v1/keys.

Buy a pack

Packs are one-time payments through Stripe Checkout. The amount is added to the account when the payment clears, once per order, even if Stripe sends the notification more than once.

GET  /v1/policy                              -> {"version": "...", "refund": "...", "terms": [...]}
POST /v1/web/orders  {"pack_id":"pack_10","agree":true,"policy_version":"..."}   (console session)
POST /v1/orders     {"pack_id":"pack_10","agree":true,"policy_version":"..."}   (API key, for bots)
  -> {"order_id": 12, "status": "pending", "checkout_url": "https://checkout.stripe.com/..."}
GET  /v1/orders/12                           (API key) -> {"status": "paid", "emails": 36360, "usd": 10, "fee_usd": 0.5, "total_usd": 10.5}

Pack ids: pack_5 ($5, +18,180 email credits), pack_10 ($10, +36,360), pack_50 ($50, +181,800), each plus the purchase fee (5%, at least $0.50). Credits don't renew and last until used. Before any order, show the person the refund policy and terms from GET /v1/policy and send agree: true with its version only once they agree; without it the order is refused with terms_not_agreed. Payments are one-time, there is no subscription or automatic charge, and they are not refundable in general. Send a person to checkout_url to pay. Orders list at GET /v1/web/orders in the console.

Errors

StatusDetailMeaning
400from_must_use_email59_com, invalid_from, empty_body, invalid_emailFix the request. The from domain is always email59.com.
401missing_api_key, invalid_api_keySend the bearer key.
403from_is_reservedThe from name reads as email59 or its staff (admin, support, security, email59-billing). Send from your app's or business's name: yourapp, yourapp-support.
403from_is_a_mailboxThe from name is somebody's mailbox at email59.com. Only its owner sends from it; pick another name.
403test_key_not_allowedA test key can send and read test emails. Making keys, buying and editing lists need a live key or a session key.
422content_not_allowed with reasonsThe email breaks the sending rules: marketing (the phrases found are in marketing) or, from a kid-safe address, not_kid_safe (bad language or sexual words; words counts them) or not_english (passages not in English). Nothing was sent or counted against your day.
403marketing_not_allowedCampaigns, category: marketing and opt-ins are closed. Send transactional email with /v1/send.
422content_refused with a one-line messageThe automated check read the email before it went out and found it breaks the policy (sexual content, phishing, pure marketing, bulk mail without an unsubscribe link). Nothing was sent, and sending is paused for 3 minutes.
429sending_too_fastSends from one account must be a few seconds apart. Nothing was sent, and sending is paused for 3 minutes (Retry-After).
429sending_pausedSending is paused after a refused or too-fast send. Wait the seconds in Retry-After and send again.
403sending_on_holdThe automated AI check found a sent email that breaks the kid-safe or no-marketing rule, so sending is on hold. Contact support@email59.com.
429test_limit_reachedA test key takes 2,000 emails in 24 hours.
401sign_in_required, session_expiredConsole calls need a current osec.one session; an expired e59s_ key needs a new POST /v1/auth.
400wrong_code, no_valid_code, email_rejectedEmail sign-in: check the code, ask for a new one, or use a real mailbox.
429too_many_codes3 codes per address per 15 minutes.
429too_soon, too_many_codes on /v1/verify/startOne code per address every 30 seconds, 5 an hour. Wait retry_after_seconds.
409too_many_keysRevoke an unused key first (10 per account).
429daily_limit_reachedToday's free emails are used up and there are not enough credits. Buy credits, or wait for resets_at (00:00 UTC) for the free emails. Nothing was sent.
429free_emails_pausedThis month's shared pool of free emails is used up. Free sending is back at resets_at (the next UTC month); credits keep working.
429daily_limit_reachedCampaigns need one email (free or credit) per accepted recipient. A campaign that doesn't fit is refused whole.
422recipient_rejected with a verdictinvalid, no_mx, disposable, blocked on /v1/send. Nothing counted against your day.
400invalid_reply_to, reply_to_must_not_use_email59_comreply_to on /v1/send is one plain address of your own, not at email59.com.
422reply_to_rejected with a verdictThe reply_to address is disposable or its domain takes no mail (no_mx). Nothing counted against your day.
400reply_to_not_available_for_campaignsCampaigns can't route answers yet. Leave reply_to out, or use /v1/send.
422no_deliverable_recipientsEvery campaign recipient was rejected (see rejected). Nothing counted against your day.
429signup_limit_reachedToo many signups from this IP today.
502provider_errorThe provider failed. The email was given back (free emails or credits).
422unknown_packUse pack_5, pack_10 or pack_50.
502payment_provider_errorStripe did not answer. Try again.
503sending_not_configured, campaigns_not_configured, payments_not_configuredThat path is off on this server.