Docs / API / Email

Email API

Send transactional and bulk emails, schedule them for the future, cancel pending sends. Idempotent, rate-limited, fully tracked.

Send one email

POST /api/emails

Scopes: emails:send · Rate limit: per-tenant token bucket (default 600/min) · Idempotency: yes, 24h TTL

Request body

FieldTypeRequiredDescription
fromemailYesFrom address. The domain portion must be verified in SES. The IAM policy enforces this; otherwise the send fails with UPSTREAM_FAILED.
tostring \| string[]YesOne or many recipients. Limit: 50 total across To/Cc/Bcc per message.
ccemail[]NoCarbon-copy list. Counts toward the 50-recipient cap.
bccemail[]NoBlind carbon-copy list. Counts toward the cap.
replyToemail[]NoReply-To addresses (up to 5).
subjectstring (1-998)Yes*RFC 5322 says 998 chars max per header line. *Required unless templateId provides one.
htmlstring (≤500 KB)One-ofHTML body. Handlebars syntax supported. Non-ASCII auto-encoded.
textstring (≤500 KB)One-ofPlain-text body. If both html and text are present, sent as multipart/alternative.
templateIdstringOne-ofUUID or name of stored template — see Templates.
variablesobjectNoHandlebars variables. Available in subject + html + text.
streamIdstringNoSend via a named stream — see Streams.
tagsstring[]NoUp to 5 free-form tags. Filterable in analytics & events.
headersobjectNoCustom MIME headers. Keys must start with X-. List-Unsubscribe is auto-stamped.
trackOpensboolNoInject 1×1 tracking pixel into <body>. Default: false.
trackClicksboolNoRewrite all <a href> to redirect through tracking endpoint. Default: false.
personalizeboolNoAI-personalize per recipient using contact metadata + tone. Requires ai:use scope.
attachmentsarrayNoUp to 20. Each is either inline { filename, content (base64), contentType } or by reference { fileId, filename? }.
idempotencyKeystringNoCustom dedup key. Without it, a hash of the payload is used. See Idempotency.

cURL example

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

200 OK
{
  "success": true,
  "data": {
    "messageId": "0107019a-43cf-7e3d-9b8c-...",
    "status":    "sent",
    "to":        ["anna@acme.com", "billing@acme.com"]
  },
  "requestId": "fd067654-..."
}

200 — replayed (duplicate)

200 OK · status: replayed
{
  "success": true,
  "data": {
    "messageId": "",
    "status":    "replayed",
    "to":        ["anna@acme.com"]
  }
}

200 — suppressed (all recipients on suppression list)

200 OK · status: suppressed
{
  "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)

send.mjs
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)

send.py
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)

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

CodeHTTPCauseHow to fix
BAD_REQUEST400Zod validation failed (missing field, wrong type, etc.)Check the message for the offending path.
UNAUTHORIZED401Missing/invalid Bearer tokenVerify MAILGRID_KEY env is set and not revoked.
FORBIDDEN403Key lacks emails:send scopeIssue a new key with the right scope.
NOT_FOUND404Referenced templateId / streamId / fileId doesn't existVerify the id, create the resource if needed.
CONFLICT409Concurrent duplicate send (same idempotency key, in-progress)Wait and retry — the first call will complete.
RATE_LIMITED429Per-tenant rate limit exceededBack off using Retry-After header.
UPSTREAM_FAILED502SES returned an error (sandbox, invalid From, sending paused, etc.)Inspect details.body in the error response.

Retry strategy

Send a batch

POST /api/emails/batch

Up to 100 messages dispatched in parallel. Counts as one rate-limit token (limited internally by AGENT_CONCURRENCY, default 8).

Request body

batch
{
  "emails": [
    { "from": ..., "to": ..., "subject": ..., "html": ... },
    { ... },
    ...
  ]
}

Each array entry is a full SendEmailRequest. Per-item failures don't abort the batch.

Response

batch result
{
  "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

POST /api/emails/schedule

Same body as POST /api/emails, plus:

FieldTypeRequiredDescription
sendAtISO-8601 UTCYesWhen 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

StateMeaning
pendingWaiting for sendAt to pass.
sentCron flushed it through SES successfully.
cancelledUser cancelled via DELETE.
failedSES rejected the message; error in last_error.

Cancel a scheduled send

DELETE /api/emails/schedule/:id

Returns 404 if the id doesn't exist or has already been sent. Returns 200 with { cancelled: true } on success.

Performance characteristics

Operationp50 latencyp99Bottleneck
Single send (Simple content)~180 ms~450 msSES API round-trip from CF edge
Single send with attachment~260 ms~700 msR2 fetch + MIME build + SES
Batch of 100~3.2 s~6 sSES rate limit (1/sec/account in sandbox)
Idempotent replay~25 ms~80 msD1 read only — no SES call
Suppressed (all recipients)~30 ms~90 msD1 read only
Why Mailgrid is fast

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.