> **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 a message

`POST /v1/send`

- Host: `https://ingest.sendops.dev`
- Guide (idempotency, limits, retries): [Send API guide](https://developers.sendops.dev/api-reference/send-api)
- Authentication: required (Bearer token)
- Required scope: `api.send`

Sends one message through the organization's AWS SES account.

**Two body formats.** Structured JSON (`Content-Type: application/json`)
is the documented default: SendOps builds the MIME message from
`from`, `to`, `subject`, `html`/`text`, `attachments` and `headers`, or
renders a stored `template` with `variables`. Raw MIME
(`Content-Type: message/rfc822`) is also accepted for senders that
already build their own messages, such as an SMTP relay or a migration
from `SendRawEmail`; the body is sent as-is, with envelope recipients
taken from its `To`, `Cc` and `Bcc` headers. JSON is the default
because it is what lets SendOps validate the request, render templates
and report a precise `400`; raw MIME exists so that nothing a customer
sends today through SES is impossible here.

A body that is neither `application/json` nor `message/rfc822` is
`400 validation_failed` with `field: Content-Type`. A structured body
is decoded strictly: an unknown member is a `400` naming it.

`200` means SES accepted the message and returns its `message_id`.
`202` is answered by the edge only, never by the origin: the request is
stored and will be sent when the origin recovers. See the API
description for idempotency, size and retry rules.

## Headers

- `Idempotency-Key` (string, optional) — A client-chosen key, unique per message (a UUID is ideal). Reuse it
when retrying the same message: a repeat with the same body for at
least 7 days replays the original response instead of sending again.
The same key with a different body is `409 idempotency_key_conflict`.
If absent, one is generated for the request. At most 255 characters
(`400 validation_failed` on `Idempotency-Key` otherwise).

## Request body

Content type: `application/json`

```json
{
  "from": "Acme <billing@acme.com>",
  "to": [
    "Jane Doe <jane@example.com>"
  ],
  "subject": "Your invoice #1042",
  "html": "<p>Hi Jane, your invoice is attached.</p>",
  "text": "Hi Jane, your invoice is attached."
}
```

## Example request

```bash
curl -X POST 'https://ingest.sendops.dev/v1/send' \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Idempotency-Key: 7b0d4c4e-2f1a-4d8e-9c5b-3a6f1e2d9b70" \
  -H "Content-Type: application/json" \
  -d '{"from":"Acme <billing@acme.com>","to":["Jane Doe <jane@example.com>"],"subject":"Your invoice #1042","html":"<p>Hi Jane, your invoice is attached.</p>","text":"Hi Jane, your invoice is attached."}'
```

## Responses

### 200 — Sent. SES accepted the message.

Content type: `application/json`

```json
{
  "status": "sent",
  "message_id": "string",
  "request_id": "string"
}
```

### 202 — Queued by the edge, because the origin was unavailable or shedding
load. The request is stored durably and will be sent on recovery.
Poll `GET /v1/messages/{request_id}` for the outcome. Never
returned by the origin.

Content type: `application/json`

```json
{
  "status": "queued",
  "request_id": "string"
}
```

### 400 — The request is malformed or fails validation (`code:
validation_failed`). Terminal: fix the request before retrying. The
edge also answers it, with `X-SendOps-Path: rejected`, when the body
was cut off mid-upload; resend the whole request.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "field": "string"
}
```

### 401 — Missing, malformed, unknown or revoked API key, or a non-API-key credential such as an OAuth token (`code: invalid_key`); or a rotated key past its 24-hour grace window (`code: key_expired`). Carries a `WWW-Authenticate: Bearer` challenge. The edge answers the same codes itself, with `X-SendOps-Path: fallback`, when it checks the key before queueing a send. A revoked key is `invalid_key`; `key_revoked` is never an HTTP response, only a lookup's `error.code`.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 403 — The key lacks the `api.send` scope (`code: invalid_scope`), or it is an `sk_test_` key and a recipient is neither an SES mailbox-simulator address nor on one of the organization's own verified, managed identities (`code: test_environment_forbidden`), or the request did not come through the edge at `ingest.sendops.dev` (`code: edge_required`). Nothing was sent. The edge answers `invalid_scope` itself, with `X-SendOps-Path: fallback`, when it checks the key before queueing.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 409 — The `Idempotency-Key` was already used for a different request body, or
a request under the same key is still in flight (`code:
idempotency_key_conflict`). Use a new key for a new message. The
in-flight case carries `Retry-After`: retry with the same key after it
to get the outcome.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 413 — The request body exceeds 40 MB (`code: payload_too_large`).

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 429 — Rate limit exceeded (`code: rate_limited`), or the organization has
used its plan's monthly fair-use number of API sends (`code:
send_quota_exceeded`, with `limit`, `used` and `resets_at`). Nothing
was sent. Retry after `Retry-After`; for `send_quota_exceeded` that is
the moment the billing period resets. While the edge is queueing
sends, it also limits each key to 100 requests per 10 seconds and
answers `rate_limited` itself, with `Retry-After: 10` and
`X-SendOps-Path: fallback`.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 500 — Unexpected server error (`code: internal_error`). Quote `request_id` to support.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 503 — Temporarily unable to accept the request. `code: overloaded` with
`X-SendOps-Shed: 1` when SendOps is shedding load;
`code: upstream_unavailable` when the organization's AWS SES throttled,
is out of daily quota, could not be reached, or failed without
confirming the send (never with `X-SendOps-Shed`). The edge also
answers `upstream_unavailable` with `Retry-After: 2` and
`X-SendOps-Path: fallback` when it cannot reach the SendOps origin,
the origin times out, or recent failures have it skipping the origin
for a few seconds; and with `X-SendOps-Path: direct` plus
`X-SendOps-Reason: unreplayable` when the origin failed after a body
over 10 MB was already streamed to it. Both codes are retryable after
`Retry-After`, with the same `Idempotency-Key`. A definite SES
refusal of the message (`MessageRejected`, sending paused, unverified
MAIL FROM) is a `400 validation_failed` with SES's reason instead.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "limit": 0,
  "used": 0,
  "resets_at": "2026-05-17T20:00:00Z",
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```
