Send API

Search Documentation

Search across all developer documentation

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:

EndpointDoes
POST /v1/sendSends 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 APISend API
Hostapi.sendops.devingest.sendops.dev
DoesRead events and reports, manage definitions, run broadcastsSend one transactional message, and look it up
CredentialAPI key or OAuth access tokenAPI key only. An OAuth access token is refused with 401 invalid_key.
Scopeapi.*.view / api.*.manageapi.send
Validation errors422 validation_failed400 validation_failed
AvailabilityGenerally availableEarly 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 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 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.

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
fromRequired. 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.
toRequired. 1 to 50 recipients, each address or Name <address>.
subjectRequired with html / text. With template it is optional and is passed to the template as its subject variable.
html, textThe 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_toWhere 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.
headersExtra 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.
templateThe id or name of a stored template, rendered with variables. It must be deployed to your SES account. It excludes html, text and attachments.
variablesValues 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.

StatusBodyMeaning
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"
statusMeaning
sentSES accepted it. message_id is set.
in_flightIt is being sent, or the last attempt ended without SES confirming either way. Retry the send with the same Idempotency-Key (see Retrying safely).
failedIt 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 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 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 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.

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_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.

PlanLive sends per billing period
Free3,000
Team100,000
Business1,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 202 is 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.

StatuscodeRetry?
400validation_failedNo. 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.
401invalid_key, key_expiredNo. Fix the key.
403invalid_scopeNo. The key lacks api.send.
403test_environment_forbiddenNo. A test key named a recipient outside the sandbox rules.
404not_foundNo. Unknown path, unknown request_id, or the Send API is not on for your organization.
409idempotency_key_conflictOnly when it carries Retry-After (the first request is still in flight): retry with the same key after it. Otherwise use a new key.
413payload_too_largeNo. Shrink the body.
429rate_limitedYes, after Retry-After.
429send_quota_exceededNot until resets_at.
500internal_errorYes, with backoff and the same Idempotency-Key.
503upstream_unavailableYes, after Retry-After, with the same Idempotency-Key.
503overloadedYes, 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.
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

HeaderOnValue
X-SendOps-Pathevery responsedirect (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-KeyPOST /v1/sendThe key the request was processed under, including one generated for you
Idempotent-Replayeda replaytrue when the response is the stored outcome of an earlier request with the same key
X-SendOps-Tierevery authenticated POST /v1/send response, errors includedcritical, 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-After429, 503, an in-flight 409Seconds to wait
X-SendOps-Sheda shedding 5031
X-SendOps-Reasonsome 503sunreplayable: a streamed body over 10 MB could not be held for a retry. Resend it with the same key.
X-Request-Idresponses from SendOps, and every 202A request id. On a 202 it is the request_id. Quote the body’s request_id to support.