Emails

POST /api/v1/emails

Send a single email. Source: src/app/api/v1/emails/route.ts.

Request

{
  "from": "sender@yourdomain.com",
  "to": ["recipient@example.com"],
  "subject": "Hello",
  "html": "<p>Hi</p>",
  "text": "Hi",
  "tags": ["welcome"],
  "metadata": { "campaign": "launch" },
  "scheduled_at": "2026-05-01T12:00:00Z",
  "attachments": [
    { "filename": "invoice.pdf", "content": "<base64>", "content_type": "application/pdf" }
  ],
  "template": "welcome-email",
  "variables": { "name": "Fareed" }
}

Validation (sendEmailSchema):

Field Type Rules
from string (email) required
to string[] (email) 1–50 items
subject string required unless template supplies it
html string optional
text string optional
tags string[] max 10
metadata Record<string,string> optional
scheduled_at ISO 8601 UTC if in future (>1s), message stored with status: "scheduled"
attachments object[] max 10 — { filename, content (base64), content_type }
template string slug of a template — subject/html/text derived from it when omitted
variables object string/number/boolean values, substituted into {{key}}

Headers

  • Authorization: Bearer <key> — required
  • Idempotency-Key: <uuid> — optional, 24h replay window

Response — 200

Immediate send:

{ "id": "msg_...", "channel": "email", "status": "sent", "created_at": "..." }

Scheduled:

{
  "id": "msg_...",
  "channel": "email",
  "status": "scheduled",
  "scheduled_at": "2026-05-01T12:00:00.000Z",
  "created_at": "..."
}

Errors

  • 401 UNAUTHORIZED — missing/invalid key or expired key
  • 422 VALIDATION_ERROR — zod failure, missing subject with no template, unknown template
  • 429 USAGE_LIMIT_EXCEEDED — free plan cap hit
  • 429 RATE_LIMITED — per-plan burst exceeded
  • 500 INTERNAL_ERROR — SES failed (message inserted with status: "failed")

POST /api/v1/emails/batch

Scope: send:email. maxDuration pinned to 300s. Accepts two shapes:

Shape 1 — per-item array (1–100 items)

Each item is one envelope. Item's to array becomes the visible TO header so multi-recipient emails work (e.g. to: ["a@", "b@"] lands as To: a@, b@ — both recipients see each other).

[
  { "from": "hello@yourdomain.com", "to": ["a@example.com"], "subject": "Personalized 1", "html": "<p>hi 1</p>" },
  { "from": "hello@yourdomain.com", "to": ["b@example.com"], "subject": "Personalized 2", "html": "<p>hi 2</p>" }
]

Shape 2 — bulk broadcast (one body to N recipients)

Each to entry becomes its own envelope — recipients don't see each other, each gets their own message id, tracking pixel, and unsubscribe header. Up to 100 recipients per call.

{
  "from": "hello@yourdomain.com",
  "to":   ["a@example.com", "b@example.com", "c@example.com"],
  "subject": "Newsletter",
  "html": "<h1>This month's update</h1>",
  "reply_to": ["support@yourdomain.com"],
  "tags": ["newsletter"],
  "metadata": { "campaign": "may_2026" },
  "attachments": [
    { "filename": "may.pdf", "url": "https://example.com/may.pdf", "content_type": "application/pdf" }
  ]
}

Mirrors SendGrid Personalizations / Resend's bulk send. Internally fans out to per-recipient items; the gates and response shape are identical to Shape 1.

Response (both shapes)

{
  "data": [
    { "index": 0, "id": "msg_...", "status": "sent",   "error": null },
    { "index": 1, "id": null,      "status": "failed", "error": { "code": "SUPPRESSED", "message": "..." } }
  ],
  "total": 2,
  "succeeded": 1,
  "failed": 1
}
  • Usage incremented per successful item.
  • Plan quota enforced upfront — batches exceeding remaining quota are rejected (429) before any send.
  • Suppressed recipients fail per-item with SUPPRESSED; rest of the batch proceeds.
  • Idempotency-Key not honored on batch — use single-send /v1/emails with Idempotency-Key for at-most-once.
  • template + scheduled_at not supported on either shape — use single-send.

GET /api/v1/emails

List emails for the auth'd org + environment.

Query params

Param Default Notes
limit 20 max 100
cursor base64url of `{ISO}
status sent / delivered / bounced / failed / scheduled
tag messages carrying this tag
to exact recipient address match
created_after ISO 8601; inclusive lower bound on created_at
created_before ISO 8601; inclusive upper bound on created_at

Response

{
  "data": [{
    "id": "msg_...",
    "channel": "email",
    "status": "delivered",
    "from": "a@x.com",
    "to": "b@y.com",
    "subject": "...",
    "created_at": "...",
    "sent_at": "...",
    "delivered_at": "..."
  }],
  "has_more": true,
  "next_cursor": "eyIyMDI2..."
}

Pass next_cursor as ?cursor= on the next request.


GET /api/v1/emails/{id}

Retrieve one email by ID. 404 if not owned by caller's org.