Email API
Send transactional and bulk emails, schedule them for the future, cancel pending sends. Idempotent, rate-limited, fully tracked.
Send one email
Scopes: emails:send · Rate limit: per-tenant token bucket (default 600/min) · Idempotency: yes, 24h TTL
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | Yes | From address. The domain portion must be verified in SES. The IAM policy enforces this; otherwise the send fails with UPSTREAM_FAILED. | |
to | string \| string[] | Yes | One or many recipients. Limit: 50 total across To/Cc/Bcc per message. |
cc | email[] | No | Carbon-copy list. Counts toward the 50-recipient cap. |
bcc | email[] | No | Blind carbon-copy list. Counts toward the cap. |
replyTo | email[] | No | Reply-To addresses (up to 5). |
subject | string (1-998) | Yes* | RFC 5322 says 998 chars max per header line. *Required unless templateId provides one. |
html | string (≤500 KB) | One-of | HTML body. Handlebars syntax supported. Non-ASCII auto-encoded. |
text | string (≤500 KB) | One-of | Plain-text body. If both html and text are present, sent as multipart/alternative. |
templateId | string | One-of | UUID or name of stored template — see Templates. |
variables | object | No | Handlebars variables. Available in subject + html + text. |
streamId | string | No | Send via a named stream — see Streams. |
tags | string[] | No | Up to 5 free-form tags. Filterable in analytics & events. |
headers | object | No | Custom MIME headers. Keys must start with X-. List-Unsubscribe is auto-stamped. |
trackOpens | bool | No | Inject 1×1 tracking pixel into <body>. Default: false. |
trackClicks | bool | No | Rewrite all <a href> to redirect through tracking endpoint. Default: false. |
personalize | bool | No | AI-personalize per recipient using contact metadata + tone. Requires ai:use scope. |
attachments | array | No | Up to 20. Each is either inline { filename, content (base64), contentType } or by reference { fileId, filename? }. |
idempotencyKey | string | No | Custom dedup key. Without it, a hash of the payload is used. See Idempotency. |
cURL example
curl -X POST https://api.mailgrid.space/api/emails \ -H "Authorization: Bearer $MAILGRID_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1024-receipt" \ -d '{ "from": "billing@mailgrid.space", "to": ["anna@acme.com", "billing@acme.com"], "replyTo": ["support@mailgrid.space"], "subject": "Receipt for order #1024", "html": "<h1>Thanks, {{name}}</h1><p>Total: ${{total}}</p>", "text": "Thanks {{name}}. Total: ${{total}}", "variables": { "name": "Anna", "total": "42.00" }, "streamId": "transactional-prod", "trackOpens": true, "trackClicks": true, "tags": ["receipt", "v3"], "headers": { "X-Order-Id": "1024" }, "attachments": [ { "fileId": "logo-cached-fid" }, { "filename": "receipt-1024.pdf", "content": "JVBERi0xLj...", "contentType": "application/pdf" } ] }'
Response shapes
200 — sent successfully
{
"success": true,
"data": {
"messageId": "0107019a-43cf-7e3d-9b8c-...",
"status": "sent",
"to": ["anna@acme.com", "billing@acme.com"]
},
"requestId": "fd067654-..."
}200 — replayed (duplicate)
{
"success": true,
"data": {
"messageId": "" ,
"status": "replayed",
"to": ["anna@acme.com"]
}
}200 — suppressed (all recipients on suppression list)
{
"success": true,
"data": {
"messageId": "suppressed",
"status": "suppressed",
"to": ["unsubscribed@example.com"]
}
}Client libraries
There are no official Mailgrid SDKs yet — the REST surface is small enough to call directly. Drop-in patterns:
Node.js (fetch)
async function send(payload) { const r = await fetch('https://api.mailgrid.space/api/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MAILGRID_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); const json = await r.json(); if (!json.success) throw new Error(`Mailgrid ${json.code}: ${json.message}`); return json.data; } await send({ from: 'hello@yourdomain.com', to: 'user@example.com', subject: 'Hi', html: '<p>Hello</p>', });
Python (httpx)
import os, httpx def send(**payload): r = httpx.post( "https://api.mailgrid.space/api/emails", headers={ "Authorization": f"Bearer {os.environ['MAILGRID_KEY']}", "Content-Type": "application/json", }, json=payload, timeout=15, ) j = r.json() if not j.get("success"): raise RuntimeError(f"Mailgrid {j['code']}: {j['message']}") return j["data"] send( from_="hello@yourdomain.com", to="user@example.com", subject="Hi", html="<p>Hello</p>", )
Go (net/http)
package mailgrid import ( "bytes"; "encoding/json"; "fmt"; "net/http"; "os" ) func Send(p map[string]any) (map[string]any, error) { body, _ := json.Marshal(p) req, _ := http.NewRequest("POST", "https://api.mailgrid.space/api/emails", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("MAILGRID_KEY")) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var j map[string]any json.NewDecoder(resp.Body).Decode(&j) if ok, _ := j["success"].(bool); !ok { return nil, fmt.Errorf("%v", j["message"]) } return j["data"].(map[string]any), nil }
Errors
| Code | HTTP | Cause | How to fix |
|---|---|---|---|
BAD_REQUEST | 400 | Zod validation failed (missing field, wrong type, etc.) | Check the message for the offending path. |
UNAUTHORIZED | 401 | Missing/invalid Bearer token | Verify MAILGRID_KEY env is set and not revoked. |
FORBIDDEN | 403 | Key lacks emails:send scope | Issue a new key with the right scope. |
NOT_FOUND | 404 | Referenced templateId / streamId / fileId doesn't exist | Verify the id, create the resource if needed. |
CONFLICT | 409 | Concurrent duplicate send (same idempotency key, in-progress) | Wait and retry — the first call will complete. |
RATE_LIMITED | 429 | Per-tenant rate limit exceeded | Back off using Retry-After header. |
UPSTREAM_FAILED | 502 | SES returned an error (sandbox, invalid From, sending paused, etc.) | Inspect details.body in the error response. |
Retry strategy
- 4xx errors are not retryable. The request itself is broken.
- 429: back off using
Retry-After(seconds). Don't hammer. - 502/503: retry with exponential backoff. Mailgrid's idempotency layer guarantees you won't double-send.
- 500: retry once. If it persists, file an issue with the
requestId. - SES rate limits (separate from Mailgrid's) trip the upstream retry inside the Worker — you'll see them as
UPSTREAM_FAILEDonly if all 3 internal attempts fail.
Send a batch
Up to 100 messages dispatched in parallel. Counts as one rate-limit token (limited internally by AGENT_CONCURRENCY, default 8).
Request body
{
"emails": [
{ "from": ..., "to": ..., "subject": ..., "html": ... },
{ ... },
...
]
}Each array entry is a full SendEmailRequest. Per-item failures don't abort the batch.
Response
{
"success": true,
"data": {
"total": 100,
"sent": 97,
"suppressed": 2,
"failed": 1,
"results": [
{ "index": 0, "status": "sent", "messageId": "..." },
{ "index": 1, "status": "suppressed" },
{ "index": 2, "status": "failed", "error": "SES sandbox" }
]
}
}The batch endpoint returns 200 OK even when some items fail. Inspect results per item.
Schedule a send
Same body as POST /api/emails, plus:
| Field | Type | Required | Description |
|---|---|---|---|
sendAt | ISO-8601 UTC | Yes | When the send should happen. Must be at least 5 minutes in the future. |
When does it flush?
A cron trigger (17 * * * *) runs hourly and picks up any scheduled_emails rows where send_at <= now() and status = 'pending'. Each row is then run through the same send() path — same suppression, idempotency, tracking, and stream rules.
Practical latency: ≤ 60 minutes after sendAt. If you need second-level precision, schedule for the cron tick before your target time.
State machine
| State | Meaning |
|---|---|
pending | Waiting for sendAt to pass. |
sent | Cron flushed it through SES successfully. |
cancelled | User cancelled via DELETE. |
failed | SES rejected the message; error in last_error. |
Cancel a scheduled send
Returns 404 if the id doesn't exist or has already been sent. Returns 200 with { cancelled: true } on success.
Performance characteristics
| Operation | p50 latency | p99 | Bottleneck |
|---|---|---|---|
| Single send (Simple content) | ~180 ms | ~450 ms | SES API round-trip from CF edge |
| Single send with attachment | ~260 ms | ~700 ms | R2 fetch + MIME build + SES |
| Batch of 100 | ~3.2 s | ~6 s | SES rate limit (1/sec/account in sandbox) |
| Idempotent replay | ~25 ms | ~80 ms | D1 read only — no SES call |
| Suppressed (all recipients) | ~30 ms | ~90 ms | D1 read only |
The Worker runs at the closest Cloudflare POP to the client. SigV4 is hand-written in 150 lines instead of the 2 MB AWS SDK. D1 + R2 + KV are all colocated with the Worker. The slowest leg by far is the round-trip to SES, and we can't make AWS faster — but we minimize everything else.