---
title: "Simple Email"
description: "A drop-in send surface under /v1/simple-email that speaks the wire format of a widely used JavaScript email SDK. Point the SDK's baseUrl at Primitive and keep your send code unchanged."
canonical: "https://docs.primitive.dev/docs/simple-email"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Simple Email

Simple Email is a compatibility wire format: a set of endpoints under `/v1/simple-email` whose request and response shapes match a widely used JavaScript email SDK. If your application already sends through that SDK, point its `baseUrl` at Primitive and keep your send code as it is:


```
const client = new EmailSdk(process.env.PRIMITIVE_API_KEY!, {
  baseUrl: 'https://api.primitive.dev/v1/simple-email',
});


const { data, error } = await client.emails.send({
  from: 'support@yourdomain.com',
  to: 'alice@example.com',
  subject: 'Order confirmed',
  text: 'Your order is on its way.',
});
```


Every send dispatches through the exact same path as [POST /v1/send-mail](/docs/sending): same authentication, validation, [recipient gates](/docs/sending#who-you-can-send-to), rate limits, and send records. Simple Email is a translation layer, not a second send pipeline, so a send made here shows up on `GET /v1/sent-emails` like any other.

## Base URL and Authentication


```
https://api.primitive.dev/v1/simple-email
```


Authentication is a normal Primitive credential: `Authorization: Bearer prim_<api_key>`. There is no separate key type for this surface.

## Endpoints

Method and pathPurpose`POST /v1/simple-email/emails`Send one email. Returns `200` with a bare `{ "id": ... }`.`POST /v1/simple-email/emails/batch`Send up to 100 emails in one request (bare JSON array body).`GET /v1/simple-email/emails/{id}`Read a sent email back in the compat shape.`PATCH /v1/simple-email/emails/{id}`Reschedule a still-scheduled send (`{ "scheduled_at": ... }`).`POST /v1/simple-email/emails/{id}/cancel`Cancel a still-scheduled send.

## Response and Error Shapes

This surface does not use the standard Primitive `{ success, data, error }` envelope. Success bodies are the bare compat objects (`{ "id": ... }` on send, `{ "object": "email", "id": ... }` on reschedule and cancel), and errors are:


```
{
  "name": "invalid_parameter",
  "message": "`template` is not supported by this endpoint",
  "statusCode": 422
}
```


`statusCode` mirrors the HTTP status, so SDK clients can branch without reading the status line. Rate-limited responses keep their `Retry-After` and `RateLimit-*` headers.

Internal error codes are mapped to the compat vocabulary; the ones you are most likely to branch on:

Compat `name`StatusWhen`invalid_parameter`422Validation failure, or an unsupported field (see below). Also used at 403 for a gated recipient (the message names the denied address) and at 409 for a reschedule or cancel that lost to execution.`missing_required_field`422`from`, `to`, `subject`, or both of `text`/`html` missing.`invalid_api_key`401Missing or invalid bearer token.`invalid_from_address`403The `from` domain is not a verified outbound sender for your org.`restricted_api_key`403Outbound mail is not enabled for this account.`invalid_access`403A usage cap or credit gate refused the send.`invalid_idempotency_key`409Idempotency conflict.`rate_limit_exceeded`429Rate limited; honor `Retry-After`.`not_found`404No email with this id is visible to your org.`application_error`500/5xxUnexpected internal failure.

## Sending

`POST /v1/simple-email/emails` accepts these fields:

- `from`, `to`, `subject` (required), plus at least one of `text` or `html`;

- `cc`, `bcc`, `reply_to` (string or array of strings, like `to`);

- `headers` (same [allowlist](/docs/sending#custom-headers) as `/v1/send-mail`);

- `tags` (same [rules](/docs/sending#tags) as `/v1/send-mail`);

- `scheduled_at` (same [bounds](/docs/sending#scheduled-sends): strictly future, at most 30 days out);

- `attachments`, each with `filename`, `content` (a base64 string), and optional `content_type` and `content_id` (note the field is `content` here, not `content_base64`).

Field semantics are enforced by the canonical send-mail validator after translation, so address syntax, the header allowlist, tag charset, recipient caps, and scheduled-send bounds behave exactly as documented on [Sending Mail](/docs/sending). Sends on this surface are always asynchronous; there is no equivalent of `wait`.

### Unsupported Fields Return 422

Any field outside the supported set is rejected with a `422` naming the field, including compat SDK features Primitive deliberately does not implement here (for example `template` and `topic_id`). This is by design: SDKs on this wire format are commonly configured with a fallback provider, and a `422` routes the message through the fallback instead of failing the send outright.

The same applies inside `attachments`: remote-file attachments (`path`) are rejected with a `422`. Buffer-object `content` (a JSON-serialized Node Buffer) is accepted and converted to base64 for you; a plain base64 string works too.

## Reading a Send Back

`GET /v1/simple-email/emails/{id}` returns the compat read-back shape:


```
{
  "object": "email",
  "id": "...",
  "to": ["alice@example.com"],
  "from": "support@yourdomain.com",
  "subject": "Order confirmed",
  "html": null,
  "text": "Your order is on its way.",
  "created_at": "2026-07-17T12:00:00.000Z",
  "last_event": "delivered",
  "bcc": null,
  "cc": null,
  "reply_to": null,
  "message_id": "<...>",
  "scheduled_at": null
}
```


`last_event` collapses Primitive's internal statuses into the compat vocabulary: `queued`, `sent`, `delivered`, `bounced`, `delivery_delayed`, `failed`, `scheduled`, or `canceled`. Statuses without a direct equivalent report `sent` (the send was accepted; the compat vocabulary cannot say more). For the full-fidelity record, read the same id from `GET /v1/sent-emails/{id}`.

## Rescheduling and Canceling

`PATCH /v1/simple-email/emails/{id}` with `{ "scheduled_at": ... }` moves a still-scheduled send; `POST /v1/simple-email/emails/{id}/cancel` cancels one. Both acknowledge with `{ "object": "email", "id": ... }` and carry the same compare-and-swap semantics as the [canonical endpoints](/docs/sending#rescheduling-and-canceling): once the send has executed or was already canceled, they return a `409` whose message names the current status.

## Batch Sending

`POST /v1/simple-email/emails/batch` takes a bare JSON array of 1 to 100 send bodies and answers with the accepted ids in input order:


```
{
  "data": [
    { "id": "..." },
    { "id": "..." }
  ]
}
```


Unlike [POST /v1/send-mail/batch](/docs/sending#batch-sending), which reports per-message results, this surface reports a single batch-level outcome, so it is adapted as all-or-nothing in two layers:

- Validate before dispatch. Every item is translated and validated up front. Any invalid item rejects the whole batch with a `422` naming the item index (`emails[3]: ...`) and zero sends dispatched, so a validation failure is never partially accepted.

- Serial fail-fast dispatch. Items are dispatched one at a time, in order, and the first failure returns immediately; nothing after the failing item is ever dispatched.

What serial dispatch cannot undo: items accepted before the failing one are already queued for delivery and cannot be recalled. The batch error message names the failing item's index and the count of already-accepted items, and tells you not to re-send the full batch; retry only the items from the failing index onward, or route just those through your fallback.

Two more batch behaviors to know:

- A batch-level `Idempotency-Key` header is applied per item with an index suffix (`<key>:<index>`), so retrying the same batch with the same key replays each item instead of sending it again. The suffixed key has to fit the 255-character key limit, so the longest batch key accepted is 253 characters for a batch of 1 to 10 emails and 252 characters for 11 to 100. A longer key is rejected with a `422` `invalid_parameter` error and nothing in the batch is sent. A key over 255 characters fails the general key-length check first, so its message names the 255-character limit; a key of 255 or fewer that is still too long for the batch gets a message stating the maximum for that batch size. Keep a batch key to 252 characters or fewer and it fits any batch. When no header is supplied, each item derives its own key from its payload; in that case, byte-identical duplicate items within one batch dedupe to a single send and echo the same id in each slot. The same holds for a keyless single send: an identical send inside the same 5-minute window is not sent again and returns the first send's id (see [Sends without a key](/docs/sending#sends-without-a-key)). Response bodies on this surface keep their compat shape, so a replay is reported in headers: `Idempotent-Replayed: true` on any response where a send was answered by an earlier one, plus `Idempotent-Replayed-Count: <n>` on a batch.

- Serial dispatch means latency grows linearly with batch size. Throughput-sensitive callers should prefer `POST /v1/send-mail/batch`, whose per-item results avoid whole-batch retries.

## Related Pages

- [Sending Mail](/docs/sending): the canonical send surface and full field semantics.

- [REST API](/docs/api): the standard envelope the rest of the v1 API uses.

- [Errors](/docs/errors): the canonical error-code taxonomy these compat errors map from.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
