> **Early access.** Send API is available to selected organizations and is not generally available yet. What this page describes may change before it is. To request access, email support@sendops.dev.

# Send API

The **Send API** sends one transactional email per request. SendOps validates it, builds the message (or renders one of your stored templates), and sends it through **your own AWS SES account**, on your verified identities and your sending reputation. Two endpoints make up the whole surface:

| Endpoint | Does |
|---|---|
| [`POST /v1/send`](/api-reference/endpoints/send/send-message) | Sends one message. Answers `200` with the SES `message_id`, or `202` when the request was queued to be sent shortly. |
| [`GET /v1/messages/{request_id}`](/api-reference/endpoints/send/get-send-request) | Says what became of a send request: `sent`, `in_flight` or `failed`. |

Both live on their own host, **`https://ingest.sendops.dev`**, not on `api.sendops.dev`.

## How it differs from the Public API

The [Public API](/api-reference) on `api.sendops.dev` is the control plane: it reads what your mail did and manages contacts, templates, Lists, Segments, workflows and broadcasts. It has no single-message send. The Send API is that send, and nothing else.

| | Public API | Send API |
|---|---|---|
| Host | `api.sendops.dev` | `ingest.sendops.dev` |
| Does | Read events and reports, manage definitions, run broadcasts | Send one transactional message, and look it up |
| Credential | API key or OAuth access token | **API key only.** An OAuth access token is refused with `401 invalid_key`. |
| Scope | `api.*.view` / `api.*.manage` | `api.send` |
| Validation errors | `422 validation_failed` | **`400 validation_failed`** |
| Availability | Generally available | **Early access** |

The mail itself still never leaves through infrastructure of ours: SendOps calls SES in your AWS account, so bounces, complaints, opens and clicks show up in SendOps exactly as they do for mail you send to SES yourself. A Send API message also appears on the dashboard's **Messages** page, where a **Send API** filter picks it out.

`GET /v1/messages/{request_id}` on the Send API is not the Public API's `GET /v1/messages/{id}`. The Send API one takes the `request_id` from a send response and reports the request's outcome. The Public API one takes a message id and returns the message with its SES events.

## Getting access

The Send API is in early access: it is switched on per organization and is not generally available yet. To request it, email [support@sendops.dev](mailto:support@sendops.dev?subject=Early%20access%3A%20Send%20API).

Until it is on for your organization:

- Every `/v1/*` request to `ingest.sendops.dev` with a valid API key gets a plain **`404 not_found`**, the same answer as a path that does not exist. It does not say the feature is switched off.
- The `api.send` scope is not offered when you create or edit an API key.

## Create an `api.send` key

1. In the dashboard, open **Workspace → API Keys** and click **Create API Key**.
2. Choose **Live** or **Test** (see [Test keys](#test-keys) below).
3. Tick **Send email**. Its badge reads **Send API**; the scope it grants is `api.send`.
4. Copy the key. It is shown once.

A key that holds `api.send` can send real mail from your SES account, so only someone who can already send mail from the dashboard can grant it: a member whose role includes the `broadcasts.send` or `templates.test` permission. Give a sending service a key with `api.send` alone, separate from any key that reads your data.

`api.send` is a key scope only. It is not an OAuth scope, and it does nothing on the Public API or the MCP server. Keys have the same format and lifecycle as on the Public API (`sk_live_…` / `sk_test_…`, 24-hour rotation grace, immediate revocation); see [Authentication](/api-reference/authentication).

## Send a message

```bash
curl https://ingest.sendops.dev/v1/send \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Idempotency-Key: order-8812-receipt" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <billing@acme.com>",
    "to": ["Jane Doe <jane@example.com>"],
    "subject": "Your receipt for order 8812",
    "html": "Thanks for your order, Jane.",
    "text": "Thanks for your order, Jane."
  }'
```

```json
HTTP/1.1 200 OK
X-SendOps-Path: direct
X-SendOps-Tier: standard

{
  "status": "sent",
  "message_id": "010001929a6c4c1e-3f9b2a77-6c1d-4f0e-9a51-2b7e8d4c0f13-000000",
  "request_id": "req_0192a1b4c7d87e2f9a3b5c6d7e8f9a0b"
}
```

### The JSON body

| Member | |
|---|---|
| `from` | **Required.** `address` or `Name <address>`. It must be on a domain, or be an exact email identity, that is **verified in your SES account and managed by SendOps for your organization**. A domain covers itself, not its subdomains. Anything else is `400 validation_failed` on `from`. |
| `to` | **Required.** 1 to 50 recipients, each `address` or `Name <address>`. |
| `subject` | Required with `html` / `text`. With `template` it is optional and is passed to the template as its `subject` variable. |
| `html`, `text` | The body. Send either or both. |
| `attachments` | `[{filename, content, content_type?}]`. `content` is the file's bytes, base64-encoded; `content_type` is inferred from `filename` when left out. |
| `reply_to` | Where replies go: one `address` or `Name <address>`, or an array of up to 50. Works with `html` / `text` and with `template`. SES sets it as the message's `Reply-To`. |
| `headers` | Extra message headers, name to value. `From`, `To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` and `Content-Type` are set by SendOps and cannot be overridden. A `Reply-To` header is accepted and treated as `reply_to`; set one or the other, not both. |
| `template` | The id or name of a stored template, rendered with `variables`. It must be deployed to your SES account. It excludes `html`, `text` and `attachments`. |
| `variables` | Values substituted into `template`. |

Send content **or** a template, not both. A template send needs your AWS stack to grant `ses:SendTemplatedEmail` (the latest stack version does); on an older stack SES refuses it with `400 validation_failed`, and the detail names the missing permission. Update the stack from the dashboard and retry with a new `Idempotency-Key`. The body is decoded strictly: an unknown member is a `400` that names it, so a typo never silently drops a field. There is no `cc` or `bcc` member; use raw MIME for those.

### Raw MIME

If you already build complete messages, for example from an SMTP relay or code written for SES `SendRawEmail`, send the message itself with `Content-Type: message/rfc822`. It is sent as-is. Its `From` header (exactly one) gets the same sender check as `from`, and the envelope recipients are taken from its `To`, `Cc` and `Bcc` headers.

Any other `Content-Type` is `400 validation_failed` with `field: Content-Type`.

### Test keys

An `sk_test_` key **sends for real**, under SES-sandbox rules: every recipient must be an SES mailbox-simulator address (`…@simulator.amazonses.com`) or an address on one of your own verified, managed domains or email identities. Any other recipient is `403 test_environment_forbidden` and nothing is sent.

Test and live are separate: a request made with a test key can only be looked up with a test key, and the same goes for live. Test sends never count toward the [monthly limit](#monthly-fair-use-limit).

## `200` sent, or `202` queued

`POST /v1/send` succeeds in one of two ways. Handle both.

| Status | Body | Meaning |
|---|---|---|
| `200` | `{"status": "sent", "message_id", "request_id"}` | SES accepted the message. `message_id` is the SES message id. |
| `202` | `{"status": "queued", "request_id"}` | SendOps could not send it right away, because it was briefly unavailable or shedding load. The request is **stored durably and will be sent** when it recovers, with no action from you. |

A `202` is a promise to send, not a failure: don't retry it. Store the `request_id` and, if you need the outcome, look it up (next section). The `X-SendOps-Path` response header says which way a request went: `direct` for a `200`, `fallback` for a `202`.

Requests are only queued when their body was held in full. A body over 10 MB (10,485,760 bytes), or one sent without a `Content-Length`, is streamed instead, and if SendOps fails while receiving it the answer is a retryable `503` with `X-SendOps-Reason: unreplayable` rather than a `202`.

Mail that was queued is checked when it is sent, not when it is queued. A queued request with a bad `from` ends up `failed`, with the same `code` it would have been answered with directly. Three codes appear only on a queued request that failed: `key_revoked` (the key was revoked, or expired past its rotation grace, before it was sent), and `payload_missing` or `retries_exhausted` (SendOps lost the stored request or could not send it in time). For the last two, send the message again with a **new** `Idempotency-Key`.

The key itself is checked before anything is queued, though. While requests are being queued, an unknown, revoked or other-environment key gets `401 invalid_key`, a rotated key past its grace window `401 key_expired`, and a key without `api.send` `403 invalid_scope`, with nothing stored.

## Check what happened

```bash
curl https://ingest.sendops.dev/v1/messages/req_0192a1b4c7d87e2f9a3b5c6d7e8f9a0b \
  -H "Authorization: Bearer $SENDOPS_API_KEY"
```

| `status` | Meaning |
|---|---|
| `sent` | SES accepted it. `message_id` is set. |
| `in_flight` | It is being sent, or the last attempt ended without SES confirming either way. Retry the send with the same `Idempotency-Key` (see [Retrying safely](#retrying-safely)). |
| `failed` | It was refused. `error.code` and `error.detail` say why, as the Problem `code` the send would have been answered with. |

```json
{
  "request_id": "req_0192a1b4c7d87e2f9a3b5c6d7e8f9a0b",
  "status": "failed",
  "error": {
    "code": "validation_failed",
    "detail": "billing@acme.co is not on a domain, or an email identity, that is verified and managed by this organization"
  }
}
```

After a `202`:

- The lookup answers **`503`** while SendOps is still unavailable. Retry after `Retry-After`.
- Once it is back, the lookup answers **`404 not_found`** until the queued request has been processed, which is normally seconds. Keep polling: a `404` here means "not yet", not "lost".
- Then it answers `sent` or `failed`.

There is no `queued` status on the lookup: the `404` above is how a request that is still queued looks.

A `request_id` resolves only for the organization that made the request, and only with a key of the same environment. Any other caller gets `404`. Ids from a request that was refused before it was accepted (most `400`s, every `401` and `403`, and a retryable `503`) are not stored and also `404`.

## Idempotency

**Send an `Idempotency-Key` header with every request, and send the same key when you retry the same message.** A UUID, or an id from your own system such as `order-8812-receipt`, is ideal. At most 255 characters.

- **Same key, same body.** For at least **7 days**, a repeat returns the original outcome, the `200` with the same `message_id` or the same `400`, with `Idempotent-Replayed: true`, and never sends a second copy. "Same body" means the same bytes and the same `Content-Type`.
- **Same key, different body.** `409 idempotency_key_conflict`. Use a new key for a new message.
- **Same key while the first request is still being sent.** `409 idempotency_key_conflict` with a `Retry-After`. Retry with the same key after it to get the outcome.
- **No key.** One is generated for you and returned in the response's `Idempotency-Key` header. Retrying with that key protects your retries too; a retry without it is a new message.
- Keys are scoped to your organization and to the key's environment: a test key and a live key never share one.

Only a definite answer is remembered. A `400` you get for a problem in the request itself (a bad `from`, an unknown template, an unsupported body) is not stored, so once you fix the request the same key works. A retryable `503` that proves nothing was sent (SES throttling, SES daily quota, your account's send rate) is not stored either: retrying with the same key sends.


  If SES fails in a way that may already have sent the message, the answer is `503 upstream_unavailable` and the key is held for about 60 seconds, the value of `Retry-After`. A retry inside that window is `409`. A retry after it sends again. If a duplicate in that case matters more to you than a missing message, check the message in SendOps before you retry.


## Limits

### Body size

A request body may be at most **40 MB** (41,943,040 bytes), measured as received. Base64-encoded attachments count at their encoded size, roughly a third larger than the file. Larger bodies are `413 payload_too_large`, and a body declared larger than that is refused before it is read.

A large upload that is still arriving after 60 seconds is cut off with `400 validation_failed`; resend the whole request over a faster connection, with the same `Idempotency-Key`.

### Rate limits

- **Per key, per minute.** The Send API applies the same per-minute API limit as the Public API, from the same budget: a key's Public API and Send API requests count together. Test keys get a tenth of it. Like the Public API, this is unthrottled until a limit is configured for your plan. See [Rate Limiting](/api-reference/rate-limiting).
- **While requests are being queued.** When SendOps is unavailable and requests are being answered `202`, each key may queue at most about 100 requests per 10 seconds. Past that: `429 rate_limited` with `Retry-After: 10`.
- **Your SES account's own limits.** Every send spends your SES account's send rate and daily quota. When either is exhausted the answer is `503 upstream_unavailable` with a `Retry-After`, and nothing was sent.
- **Failed authentication.** Repeated failed authentication from one address earns a temporary `429 rate_limited`, as on the Public API. Don't retry a `401`.

### Monthly fair-use limit

Each plan includes a monthly number of live sends through the Send API. It is a fair-use ceiling, never a per-message fee, and never billed as overage on any plan.

| Plan | Live sends per billing period |
|---|---|
| Free | 3,000 |
| Team | 100,000 |
| Business | 1,000,000 |

Test-key sends never count. An organization with a custom ceiling sees it on the dashboard's **Billing** page, along with how many sends it has used and when the count resets; the `limit` on a `send_quota_exceeded` response is always the ceiling that applies. Organization owners and admins get an in-app notification at 80 %, and an in-app and email notification at 100 %; the dashboard also shows a banner.

Once the limit is reached, `POST /v1/send` answers **`429 send_quota_exceeded`** until the billing period resets. Nothing is sent and your `Idempotency-Key` stays unused, so the same request with the same key goes through once there is room.

```json
{
  "type": "https://developers.sendops.dev/api-reference/errors/send_quota_exceeded",
  "title": "Send quota exceeded",
  "status": 429,
  "code": "send_quota_exceeded",
  "detail": "This organization has used its 100000 API sends for this billing period. Sends resume at 2026-11-01T00:00:00Z; upgrade the plan for a higher limit. Nothing was sent.",
  "limit": 100000,
  "used": 100000,
  "resets_at": "2026-11-01T00:00:00Z",
  "retry_after": 1468800,
  "request_id": "req_..."
}
```

`Retry-After` and `retry_after` are the seconds until `resets_at`, so they can be days. Don't sleep on them; treat this as "stop sending through the API until then". What counts:

- Every live send SES accepts counts once. A replay of the same key does not count again.
- A send that definitely did not go out (refused by SES, throttled, your daily quota) does not count.
- A request already answered `202` is always sent, even past the limit, and counts.
- `sk_test_` sends never count.

## Errors and retries

Errors are [RFC 7807 problem documents](/api-reference/errors), with the same envelope and `type` URIs as the Public API. Branch on `code`. Every problem carries a `request_id`; quote it to support.

| Status | `code` | Retry? |
|---|---|---|
| `400` | `validation_failed` | No. `field` names the input at fault (`to[2]`, `attachments[0].content`, `Content-Type`). A definite refusal by SES (`MessageRejected`, sending paused, an unverified MAIL FROM) is also a `400`, with SES's reason. |
| `401` | `invalid_key`, `key_expired` | No. Fix the key. |
| `403` | `invalid_scope` | No. The key lacks `api.send`. |
| `403` | `test_environment_forbidden` | No. A test key named a recipient outside the sandbox rules. |
| `404` | `not_found` | No. Unknown path, unknown `request_id`, or the Send API is not on for your organization. |
| `409` | `idempotency_key_conflict` | Only when it carries `Retry-After` (the first request is still in flight): retry with the same key after it. Otherwise use a new key. |
| `413` | `payload_too_large` | No. Shrink the body. |
| `429` | `rate_limited` | Yes, after `Retry-After`. |
| `429` | `send_quota_exceeded` | Not until `resets_at`. |
| `500` | `internal_error` | Yes, with backoff and the same `Idempotency-Key`. |
| `503` | `upstream_unavailable` | Yes, after `Retry-After`, with the same `Idempotency-Key`. |
| `503` | `overloaded` | Yes, after `Retry-After`, with the same `Idempotency-Key`. Carries `X-SendOps-Shed: 1`. |

### `503`: shedding and outages

A `503` always carries `Retry-After` (seconds) and a matching `retry_after` body member. There are two codes:

- **`overloaded`, with `X-SendOps-Shed: 1`**: SendOps is shedding load to protect itself. Most such requests are queued and answered `202` instead; you see the `503` only when a request could not be queued, such as a streamed body over 10 MB. `Retry-After` is short.
- **`upstream_unavailable`**: either your SES account throttled, was out of daily quota, could not be reached or failed without confirming the send, or SendOps itself could not be reached and the request could not be queued (`Retry-After: 2`). The `X-SendOps-Shed` header is never present on it.

Neither code means your request was wrong. Both mean "try again shortly, with the same key".

### Retrying safely

1. Generate the `Idempotency-Key` **once per message**, before the first attempt, and keep it with the message.
2. On `429 rate_limited`, `500` or `503`, wait at least `Retry-After` (add a little jitter), then resend the **same body with the same key**.
3. On a network error or timeout, where you got no answer at all, do the same: the key makes the retry safe whether or not the first attempt reached SES.
4. On `202`, stop. The message will be sent.
5. On any other `4xx`, stop and fix the request. Retrying changes nothing.
6. Cap attempts (four or five is plenty) and surface the failure after that.

```javascript


async function send(message, { attempts = 5 } = {}) {
  const key = randomUUID(); // once per message, reused on every retry
  for (let i = 0; i < attempts; i++) {
    let res;
    try {
      res = await fetch("https://ingest.sendops.dev/v1/send", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SENDOPS_API_KEY}`,
          "Idempotency-Key": key,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(message),
      });
    } catch {
      await sleep(backoff(i)); // no answer: retry with the same key
      continue;
    }
    if (res.status === 200 || res.status === 202) return res.json(); // sent, or queued to send
    const problem = await res.json();
    const retryable =
      res.status === 500 ||
      res.status === 503 ||
      (res.status === 429 && problem.code === "rate_limited") ||
      (res.status === 409 && res.headers.has("Retry-After"));
    if (!retryable) throw Object.assign(new Error(problem.detail), { problem });
    const wait = Number(res.headers.get("Retry-After") ?? 0) * 1000;
    await sleep(Math.max(wait, backoff(i)) + Math.random() * 250);
  }
  throw new Error("send failed after retries; the Idempotency-Key is " + key);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (i) => 250 * 2 ** i;
```

## Response headers

| Header | On | Value |
|---|---|---|
| `X-SendOps-Path` | every response | `direct` (SendOps answered), `fallback` (answered while SendOps was unavailable, including every `202`), `shed` (SendOps was shedding load), `rejected` (refused before it reached SendOps: unknown path, malformed `Authorization`, a body over 40 MB, or a body cut off mid-upload) |
| `Idempotency-Key` | `POST /v1/send` | The key the request was processed under, including one generated for you |
| `Idempotent-Replayed` | a replay | `true` when the response is the stored outcome of an earlier request with the same key |
| `X-SendOps-Tier` | every authenticated `POST /v1/send` response, errors included | `critical`, `standard` or `bulk`: your organization's delivery tier, set from your plan. After an outage, queued `critical` mail is sent first, and `standard` ahead of `bulk`. |
| `Retry-After` | `429`, `503`, an in-flight `409` | Seconds to wait |
| `X-SendOps-Shed` | a shedding `503` | `1` |
| `X-SendOps-Reason` | some `503`s | `unreplayable`: a streamed body over 10 MB could not be held for a retry. Resend it with the same key. |
| `X-Request-Id` | responses from SendOps, and every `202` | A request id. On a `202` it is the `request_id`. Quote the body's `request_id` to support. |

## Related

- [Send a message](/api-reference/endpoints/send/send-message) and [Look up a send request](/api-reference/endpoints/send/get-send-request): the endpoint reference.
- [Authentication](/api-reference/authentication): key format, rotation and revocation.
- [Errors](/api-reference/errors): every problem `code`.
- [Templates & Layouts](/api-reference/templates): the stored templates `template` renders.