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 | 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} | 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 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.
Until it is on for your organization:
- Every
/v1/*request toingest.sendops.devwith a valid API key gets a plain404 not_found, the same answer as a path that does not exist. It does not say the feature is switched off. - The
api.sendscope is not offered when you create or edit an API key.
Create an api.send key
- In the dashboard, open Workspace → API Keys and click Create API Key.
- Choose Live or Test (see Test keys below).
- Tick Send email. Its badge reads Send API; the scope it grants is
api.send. - 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.
Send a message
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": "<p>Thanks for your order, Jane.</p>",
"text": "Thanks for your order, Jane."
}' 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.
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
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). |
failed | It was refused. error.code and error.detail say why, as the Problem code the send would have been answered with. |
{
"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
503while SendOps is still unavailable. Retry afterRetry-After. - Once it is back, the lookup answers
404 not_founduntil the queued request has been processed, which is normally seconds. Keep polling: a404here means “not yet”, not “lost”. - Then it answers
sentorfailed.
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 400s, 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
200with the samemessage_idor the same400, withIdempotent-Replayed: true, and never sends a second copy. “Same body” means the same bytes and the sameContent-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_conflictwith aRetry-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-Keyheader. 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.
The one way a key can send twice
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.
- 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_limitedwithRetry-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_unavailablewith aRetry-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 a401.
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.
{
"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
202is always sent, even past the limit, and counts. sk_test_sends never count.
Errors and retries
Errors are RFC 7807 problem documents, 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, withX-SendOps-Shed: 1: SendOps is shedding load to protect itself. Most such requests are queued and answered202instead; you see the503only when a request could not be queued, such as a streamed body over 10 MB.Retry-Afteris 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). TheX-SendOps-Shedheader is never present on it.
Neither code means your request was wrong. Both mean “try again shortly, with the same key”.
Retrying safely
- Generate the
Idempotency-Keyonce per message, before the first attempt, and keep it with the message. - On
429 rate_limited,500or503, wait at leastRetry-After(add a little jitter), then resend the same body with the same key. - 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.
- On
202, stop. The message will be sent. - On any other
4xx, stop and fix the request. Retrying changes nothing. - Cap attempts (four or five is plenty) and surface the failure after that.
import { randomUUID } from "node:crypto";
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 503s | 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 and Look up a send request: the endpoint reference.
- Authentication: key format, rotation and revocation.
- Errors: every problem
code. - Templates & Layouts: the stored templates
templaterenders.