# SendOps Developer Documentation > Developer documentation and REST API reference for SendOps — the observability and management layer for Amazon SES. Covers authentication, rate limiting, pagination, errors, and every API endpoint with request/response examples. # API Overview Source: https://developers.sendops.dev/api-reference Section: API Reference > What the SendOps Public API is, what it isn't, and how it fits alongside your existing AWS SES setup. # API Overview The SendOps Public API lets you read events, manage configuration, and inspect account state programmatically. It is the same data plane your dashboard uses, exposed under `https://api.sendops.dev` and authenticated with an API key you create from your account settings. SendOps is a **control plane** on top of your AWS SES infrastructure. To send mail, call SES directly with your own AWS credentials. The SendOps API lets you read events, manage configuration, and inspect account state — it never relays or sends email on your behalf. ## What the API gives you - **Message-level observability** — list, search, and trace individual messages and the SES events they emit. - **Reporting** — deliverability and engagement aggregates, plus per-template performance. - **Suppressions** — read the account-level suppression list and look up individual recipients. - **Configuration introspection** — channels (SES configuration sets), templates, tracking domains, and SES identities. - **Account state** — the calling org's onboarding state, AWS connection status, plan, send quota, and SES production-access state. Every endpoint is documented in the sidebar, grouped by resource. The catalogue is generated from the live [OpenAPI spec](https://api.sendops.dev/v1/openapi.json). ## What the API does not do (in v1) - **No sending.** Mail is sent by your application against SES directly. The API never accepts a send. - **No browser-origin calls.** CORS is closed; the API is server-to-server only. - **No writes.** Every v1 endpoint is read-only. Configuration changes (channels, identities, tracking domains, suppressions) happen in the dashboard. - **No bulk or batch mutations.** Out of scope for v1. ## Authentication at a glance Every request carries an `Authorization: Bearer sk__<...>` header. Keys are scoped to a single organization and granted a subset of permissions (scopes) chosen when the key was created. Live and test keys share the same scope vocabulary but live keys run at the full rate limit, while test keys run at 10% of it. See [Authentication](/api-reference/authentication) for the full key lifecycle. ## Conventions - All endpoints are versioned under `/v1/` and use **JSON** request/response bodies. There is no XML or form-encoded alternative. - Timestamps are ISO 8601 in UTC (`2026-05-17T19:00:00Z`). - IDs are UUIDs in canonical hyphenated form. - Errors follow [RFC 7807 `application/problem+json`](/api-reference/errors). - Lists are [cursor-paginated](/api-reference/pagination); offsets and totals are not exposed. - Rate-limit headers appear on every response. See [Rate Limiting](/api-reference/rate-limiting). ## Getting your first response You'll need: a SendOps account, an API key with at least one scope (e.g., `api.reports.view`), and a terminal. Head to the [Quickstart](/api-reference/quickstart) for a five-minute walk-through. --- # Quickstart Source: https://developers.sendops.dev/api-reference/quickstart Section: API Reference > Five-minute path from creating an API key to your first successful request. # Quickstart This guide takes you from zero to a successful API call against your SendOps data. Plan on five minutes. The SendOps API does not send mail. Your application keeps calling AWS SES directly — the SendOps API lets you read what happened after. ## What you need - A SendOps account. - Permission to manage API keys in your org (Owner or Admin role by default). - A working terminal with `curl`, or a runtime in one of the languages below. Open **Settings → API Keys** and click **New API Key**. - Give it a recognizable name (e.g. `local-dev`, `production-readme`). - Choose **Live** unless you specifically want test-quota behaviour. - Select scopes. For this quickstart you only need `api.reports.view`. When you submit, the raw key is shown **once**. Copy it now — there is no way to retrieve it later. The `_health` endpoint costs nothing and is the fastest way to confirm a key works. A successful response looks like: If you see `401`, the key is malformed or revoked. If you see `403`, the key is valid but is missing the scope the endpoint requires. Now pull a deliverability snapshot for the last 7 days. This requires `api.reports.view`. `/v1/messages` returns up to 50 items per page by default. The `Link` header advances the cursor. You'll see something like: ; rel="next"`} /> Follow that URL exactly — the cursor is opaque; do not parse it. See [Pagination](/api-reference/pagination) for the contract. ## Where to next - Read the full [Authentication](/api-reference/authentication) page if you need to rotate keys, set per-environment quotas, or understand the test-vs-live model. - The [Errors](/api-reference/errors) page documents every error shape you can receive. - The [OpenAPI spec](https://api.sendops.dev/openapi.json) describes every endpoint, request, and response in machine-readable form. --- # Authentication Source: https://developers.sendops.dev/api-reference/authentication Section: API Reference > API key format, the Bearer header, environments, and the create/rotate/revoke lifecycle. # Authentication Every request to `https://api.sendops.dev` must carry a SendOps API key as a Bearer token. Keys are issued from the dashboard, scoped to one organization, and granted a fixed set of permissions chosen at creation time. ## The Bearer header No other authentication method is supported in v1. There are no session cookies, no signed-URL flow, no HMAC. If the header is missing, malformed, or the key has been revoked, the response is `401 Unauthorized` (or `403` when the key is otherwise valid but is missing the required scope). ## Key format Keys are 32 random bytes (Base32-encoded, no padding) prefixed with their environment: | Environment | Prefix | Example | |-------------|-------------|----------------------------------| | Live | `sk_live_` | `sk_live_KQX5...ZJ7` | | Test | `sk_test_` | `sk_test_8GH2...P4Q` | The first 8 characters after the prefix are stored in plaintext on our side so the dashboard and audit log can identify the key without exposing the secret. The secret itself is hashed (SHA-256) before storage — **once you create a key, we can never show it to you again**. If you lose it, you must rotate it. Anyone with the raw key can call the API on your behalf. Store keys in your secrets manager, never in version control. If a key leaks, revoke it immediately from the dashboard. ## Live vs. test keys Both environments share the same data plane and scope vocabulary — there is no separate test sandbox. The difference is **rate**: - **Live keys** run at the full per-org rate limit (default 600 requests/minute). - **Test keys** run at 10% of the live rate (60 requests/minute). All v1 endpoints are read-only, so the only practical difference is the rate ceiling. Use test keys for CI smoke checks and unit tests so noisy automation doesn't burn your live budget. ## Scopes Scopes are SendOps ACL permission keys. The full catalogue lives at: Scope keys are namespaced under `api.*` so they cannot be confused with the dashboard's internal ACL permissions. Every scope you can attach to a key starts with `api.`. | Scope | What it unlocks | |---------------------------------------|--------------------------------------------------------------------------| | `api.messages.view` | `GET /v1/messages`, `/v1/messages/{id}`, `/v1/messages/{id}/events`. Recipient addresses are masked unless the key also holds `api.messages.unmask_recipients`. | | `api.messages.unmask_recipients` | Unlocks the `recipient=` filter on `/v1/messages` and the full `GET /v1/recipients/{email}/messages` route (PII-gated) | | `api.reports.view` | Deliverability, engagement, template-performance reports | | `api.suppressions.view` | The account suppression list and per-recipient lookup | | `api.undeliverable.view` | The SendOps-derived undeliverable list (permanent bounces, complaints, rejects) plus operator-cleared tombstones for `?since=` polling | | `api.channels.view` | Channels (SES configuration sets) | | `api.templates.view` | Email templates | | `api.tracking.view` | Tracking subdomains and their DNS state | | `api.identities.view` | SES identities (email and domain) | | `api.account.view` | Organization snapshot via `/v1/account` | A request that hits an endpoint your key does not cover is rejected with `403 Forbidden` and an RFC 7807 problem document. See [Errors](/api-reference/errors). Pick the smallest scope set that solves your use case. Pulling deliverability dashboards into a status page only needs `api.reports.view`; reading a customer's message history needs `api.messages.unmask_recipients` and you'll want to think hard about who that key is given to. ## Creating, rotating, and revoking keys All lifecycle actions happen in the dashboard, in **Settings → API Keys**. ### Create Click **New API Key**, give it a name, choose Live or Test, and select scopes. The raw secret is displayed once — copy it now. If you lose it before storing it, immediately revoke this key and create a new one. There is no recovery path. If your org has **enforce 2FA** turned on, creating a key requires a fresh MFA challenge. ### Edit scopes You can update the scope set on an existing key without rotating it. The change is applied immediately and recorded in the audit log (`api_key.scopes_updated`). ### Rotate Rotation issues a new secret while keeping the same name and scope set. Both the old key and the new key continue to work for **24 hours** — a hard, non-negotiable grace window. After 24 hours the old key is automatically revoked by a background job. Use rotation when: - You suspect a key may have leaked but want zero-downtime cutover. - You're rolling keys on a routine schedule. Rotation does **not** require MFA — it's the safe path because the old key still works while you deploy the new one. ### Revoke Revocation is immediate. Once revoked, the key is dead — every subsequent request returns `401`. Use this when: - You know a key has leaked. - You want to retire a key that is no longer in use. If your org has **enforce 2FA** turned on, revocation requires a fresh MFA challenge. ## Lifecycle in the audit log Every key lifecycle event appears in the org audit log: - `api_key.created` — actor: the user who created it - `api_key.scopes_updated` — actor: the user who edited; includes added/removed scope diff - `api_key.rotated` — actor: the user who rotated; emitted on both the old and new key - `api_key.revoked` — actor: the user who revoked it (manual) - `api_key.grace_expired` — actor: `system`; emitted when the 24h rotation grace ends Request-level activity (which key called which endpoint when) is **not** in the audit log — that lives in the observability stack (see operational dashboards in your org settings). ## Storing keys safely A few practical rules: - Put the key in your secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler — anything but a `.env` file in version control). - Inject the secret into your runtime via environment variable, never hard-code it. - Add the key prefix (`sk_live_` or `sk_test_`) to your repository's secret-scanning denylist. - Rotate on a cadence appropriate to your blast radius (we recommend at least annually for long-lived service keys, and immediately whenever a teammate with access leaves). --- # Rate Limiting Source: https://developers.sendops.dev/api-reference/rate-limiting Section: API Reference > Per-org request quotas, the headers we return on every response, and how to handle 429s. # Rate Limiting The API enforces a **per-org sliding-window rate limit**. Every response — successful or not — carries the current state of your budget so you can pace requests proactively instead of reacting to 429s. ## The numbers | Key environment | Default limit | |-----------------|------------------------| | Live | 600 requests / minute | | Test | 60 requests / minute | Limits are shared across all keys in an org — if one key burns the budget, all keys in the org see 429s until the window slides. Need a higher ceiling for a specific use case? Contact support. ## Headers on every response ```http X-RateLimit-Limit: 600 X-RateLimit-Remaining: 412 X-RateLimit-Reset: 37 ``` - `X-RateLimit-Limit` — the ceiling that applies to this request, in requests per minute. - `X-RateLimit-Remaining` — how many more requests you can make in the current window. - `X-RateLimit-Reset` — seconds until the window slides far enough that you'd be at full capacity again. These are advisory — a healthy client paces itself off `Remaining` and only falls back to retry-on-429 as a safety net. ## What happens at the ceiling When you've consumed the budget, the next request returns: `Retry-After` is the canonical signal — sleep at least that many seconds before retrying. The same value also appears in `X-RateLimit-Reset` and the problem body's `retry_after`. ## Handling 429 in code The safe pattern is **exponential backoff with jitter**, honouring `Retry-After` as the minimum delay. setTimeout(r, retryAfter * 1000 + jitter)) } throw new Error("rate-limited after 4 attempts") }`} /> A few rules: - Never retry faster than `Retry-After`. Doing so spends budget without making progress and slows your own recovery. - Cap retries — runaway loops are the most common reason a service stays rate-limited indefinitely. - Add jitter so multiple clients don't lock-step into the same retry instant. ## Auth-failure limits (IP-based) Unauthenticated traffic and traffic from invalid keys is rate-limited **per IP**, with a tighter ceiling, so we don't get DDoS'd by credential-stuffing or scanner probes. You should never see this limit in normal use — it only trips when you're sending many requests with bad keys from the same source. If your client retries indefinitely on 401, the originating IP will hit the IP-based auth limit and start receiving 429s on every request — including ones with valid keys. Fix the underlying auth issue before retrying. ## Test vs live quotas Test keys run at 10% of the live limit. That's intentional — test keys are for CI smoke checks and unit tests, not load tests. If you find yourself wanting to load-test against test keys, use live keys against a non-production SendOps account instead. ## Tips for high-volume readers Most readers won't come close to the limit, but if you're paginating large message lists or polling reports on a tight loop: - **Cache responses.** Reports are computed from event data that updates within seconds — caching for 30–60 seconds on your side is usually safe and reduces request count dramatically. - **Use the largest `limit` you can.** Lists support `limit` up to 200 on most endpoints. Fewer requests, same data. - **Filter server-side.** Don't pull all messages and filter client-side; use the available query parameters. - **Subscribe instead of poll** where possible. For event-driven needs, configure a webhook in the dashboard rather than polling the API. --- # Errors Source: https://developers.sendops.dev/api-reference/errors Section: API Reference > Every error the SendOps API returns is an RFC 7807 problem document. This page catalogues the codes and recommended client behaviour. # Errors The API returns errors as [RFC 7807 problem documents](https://www.rfc-editor.org/rfc/rfc7807) with the content type `application/problem+json`. Every error response has the same shape, every status code maps to a `type` URI you can switch on programmatically, and every problem body is safe to log verbatim — no secrets are ever included. ## The problem document shape | Field | Meaning | |--------------|----------------------------------------------------------------------------------------| | `type` | A stable URI identifying the error class. **Switch on this**, not on `title`. | | `title` | A short human-readable summary. Suitable for surfacing to operators, not end users. | | `status` | The HTTP status, duplicated for clients that lose it (e.g. through a proxy). | | `detail` | A longer human-readable explanation. Often contains the specific scope or value. | | `instance` | The request path that produced the error. | | `request_id` | The opaque request ID. **Always include this when contacting support.** | Specific error classes may add extra fields (e.g. `429` includes `retry_after`). ## Catalogue ### 400 Bad Request — `invalid_request` The request was syntactically malformed: invalid JSON, missing required query parameter, bad enum value. Fix the request shape and retry. ### 401 Unauthorized — `unauthenticated` Authorization header missing, malformed, the key is revoked, or you used a key that doesn't exist. The body never says **why** the auth failed (we don't reveal whether the key existed) — fix the credential and retry. ### 403 Forbidden — `permission_denied` The key is valid but lacks the scope this endpoint requires. The `detail` field names the missing scope. Edit the key's scopes in the dashboard. ### 404 Not Found — `not_found` The path is well-formed but the resource doesn't exist (or doesn't exist *for this org* — we return 404 instead of 403 on cross-org lookups to avoid leaking existence). ### 409 Conflict — `conflict` A precondition for the request was not met. Reserved for future write endpoints; v1 read endpoints rarely emit this. ### 422 Unprocessable Entity — `validation_failed` The request was well-formed but a value failed business validation — e.g. a cursor that decodes to an out-of-range offset. Fix the value and retry. ### 429 Too Many Requests — `rate_limit` You've exceeded the per-org rate limit. Honour `Retry-After`. See [Rate Limiting](/api-reference/rate-limiting). ### 500 Internal Server Error — `internal_error` Something went wrong on our side. The response will include a `request_id` — include it when you contact support. **Retry with backoff** — most 500s are transient (a transient downstream stutter, a brief connection blip). If you see a sustained 500 rate, treat the API as down. ### 503 Service Unavailable — `service_unavailable` The API is temporarily degraded — a downstream dependency is briefly unavailable. The response carries `Retry-After`. Back off and retry. ## What clients should do A robust client implements the following matrix: | Status | Retry? | Strategy | |--------|-------------------------|-------------------------------------------------------| | 4xx (except 429) | **No** | Fix the request and try once more. | | 429 | **Yes** | Sleep at least `Retry-After` seconds. Add jitter. | | 500 | **Yes** (transient) | Exponential backoff (e.g. 250ms, 500ms, 1s, 2s). | | 503 | **Yes** | Sleep `Retry-After`; treat sustained as outage. | A safe retry budget is 3–4 attempts with exponential backoff + jitter, capped at ~30 seconds total wait. Beyond that, surface the failure to the caller — the API is genuinely down and retrying won't help. Every v1 endpoint is read-only and therefore idempotent — retrying is always safe. When write endpoints arrive in a later phase, they will require an explicit `Idempotency-Key` header for safe retries. ## Logging errors Log the entire problem document plus the `X-Request-ID` response header. Don't strip `detail` or `instance` — they're the most useful fields when debugging later. **Don't log the API key**. A minimal log entry looks like: ## Programmatic dispatch Switch on the `type` URI, not `status` and not `title`. `type` is stable across language ports of your client and tracks our internal taxonomy directly. --- # Pagination Source: https://developers.sendops.dev/api-reference/pagination Section: API Reference > Cursor-based pagination, the Link header, why we don't expose offsets or totals, and how to iterate safely. # Pagination Every list endpoint in the SendOps API uses **opaque cursor pagination** with the standard `Link` header. Offsets, page numbers, and totals are deliberately not exposed. ## The contract A list request accepts two query parameters: | Parameter | Default | Max | Meaning | |-----------|---------|------|---------------------------------------------------------------------| | `limit` | 50 | 200 | How many items to return. | | `cursor` | _none_ | _n/a_| Opaque value from the previous response's `Link: ...; rel="next"`. | The response includes: - The items in the response body as `{ "data": [ ... ] }`. - A `Link` header carrying the `next` cursor, if more data exists. - (Optionally) `page_info.has_more` and `page_info.next_cursor` in the body for clients that prefer not to parse headers. ## A complete iteration The response headers include: ; rel="next" { "data": [ /* 50 message records */ ], "page_info": { "has_more": true, "next_cursor": "eyJvZmZzZXQiOjUwfQ" } }`} /> To advance, **follow the URL from the `Link` header verbatim**. The cursor is opaque — its internal shape may change over time, between endpoints, or in response to filter parameters. Treat it as a string token, never decode or modify it. When the response has no `Link: ...; rel="next"` header (and `page_info.has_more` is `false`), you've reached the end. { const [u, rel] = p.trim().split(";") return [rel.split("=")[1].replace(/"/g, ""), u.slice(1, -1)] }) ) }`} /> ## Why no offset? Two reasons: 1. **Correctness under churn.** Lists are sorted by `created_at` descending. New items arrive constantly. Offset 100 in two consecutive requests can refer to different items. Cursors anchor to a specific position in the list so you never see the same row twice or skip a row between pages. 2. **Performance.** Offset pagination requires the database to walk past every skipped row. Cursors translate to direct index lookups — page 1 and page 100 cost the same. If you're tempted to ask "what page am I on?" — the answer is "doesn't matter; ask for the next page until there isn't one." ## Why no total count? Total counts are expensive on large tables and they'd lie anyway under churn. The accurate question to ask is "is there more after this page?" — and `Link` / `page_info.has_more` answers it precisely. Use the reporting endpoints. `GET /v1/reports/deliverability` returns aggregate counts (sent, delivered, bounced, complained) for a time range. Those numbers are computed off the analytics store and are designed for counting; list endpoints aren't. ## Filter parameters and cursors The cursor encodes the position **within the current filter set**. If you change a filter between requests, the previous cursor is meaningless — start over with no cursor. ## Errors specific to pagination - A malformed cursor returns `422 Unprocessable Entity` with `type: validation_failed`. Drop the cursor and start from the first page. - An expired cursor (rare — only happens if a backing store changes shape during a migration) returns the same error class. Same response: drop it and start over. ## Practical tips - **Always check for `has_more`** (or the `Link` header) before assuming you're done. A response with fewer items than `limit` does not by itself mean the list is exhausted. - **Use the largest `limit` you reasonably can.** Round-trip latency dominates time-to-completion for large lists; bigger pages = fewer round trips. - **Don't store cursors long-term.** They are valid for the current iteration only. If you're checkpointing a long-running export, record the last item's `id` and `created_at` and use those as filter inputs on resume. ; rel="next" { "data": [ /* 50 messages */ ], "page_info": { "has_more": true, "next_cursor": "eyJvZmZzZXQiOjUwfQ" } }`} responseLang="http" /> --- # Service liveness probe Source: https://developers.sendops.dev/api-reference/endpoints/meta/get-health Section: Meta > Unauthenticated liveness probe for uptime monitors and status pages. Returns a static `{status: "ok", commit, time}` payload — a 200 means the API is serving requests, not that every dependency is healthy. For dependency-aware health, watch the platform status page instead. The `commit` field is the deployed build identifier, useful for confirming which version of the API is responding. # Service liveness probe `GET /v1/_health` - Authentication: public (no auth) Unauthenticated liveness probe for uptime monitors and status pages. Returns a static `{status: "ok", commit, time}` payload — a 200 means the API is serving requests, not that every dependency is healthy. For dependency-aware health, watch the platform status page instead. The `commit` field is the deployed build identifier, useful for confirming which version of the API is responding. ## Example request ```bash curl 'https://api.sendops.dev/v1/_health' \ ``` ## Responses ### 200 — Service is up Content type: `application/json` ```json { "status": "ok", "commit": "string", "time": "2026-05-17T20:00:00Z" } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Scopes attached to the calling key Source: https://developers.sendops.dev/api-reference/endpoints/meta/get-scopes Section: Meta > Returns the scopes attached to the calling API key plus the key's identity (UUID, prefix, and environment `live`/`test`). Use this to verify a key authenticates correctly before issuing real traffic, and to confirm a recent scope change has taken effect. Any valid key may call this endpoint — no specific scope is required. Scope changes can take up to a few minutes to be reflected here. # Scopes attached to the calling key `GET /v1/_scopes` - Authentication: required (Bearer token) Returns the scopes attached to the calling API key plus the key's identity (UUID, prefix, and environment `live`/`test`). Use this to verify a key authenticates correctly before issuing real traffic, and to confirm a recent scope change has taken effect. Any valid key may call this endpoint — no specific scope is required. Scope changes can take up to a few minutes to be reflected here. ## Example request ```bash curl 'https://api.sendops.dev/v1/_scopes' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Scopes resolved from the cached API key Content type: `application/json` ```json { "key_id": "00000000-0000-0000-0000-000000000000", "prefix": "sk_live_abc12345", "environment": "live", "scopes": [ "string" ] } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List messages with cursor pagination Source: https://developers.sendops.dev/api-reference/endpoints/messages/list-messages Section: Messages > Returns one cursor-paginated page of message summaries, aggregated to one row per message. Results are ordered newest-first by `sent_at`. When `from` and `to` are omitted the window defaults to the trailing 30 days; a `from` older than your plan's retention is rejected with `403 plan_retention_exceeded` (the response carries a `retention_days` extension), it is not silently clamped. Use this for dashboards, reconciliation, and incremental export — page forward with the `next_cursor` from the response (or follow the `Link: <…>; rel="next"` header) until `has_more` is false. Filters compose with AND. The `recipient` filter is PII-gated: passing it without the `analytics.search.recipients` scope returns `403 invalid_scope`, not a silently-dropped filter. # List messages with cursor pagination `GET /v1/messages` - Authentication: required (Bearer token) - Required scope: `messages.view` Returns one cursor-paginated page of message summaries, aggregated to one row per message. Results are ordered newest-first by `sent_at`. When `from` and `to` are omitted the window defaults to the trailing 30 days; a `from` older than your plan's retention is rejected with `403 plan_retention_exceeded` (the response carries a `retention_days` extension), it is not silently clamped. Use this for dashboards, reconciliation, and incremental export — page forward with the `next_cursor` from the response (or follow the `Link: <…>; rel="next"` header) until `has_more` is false. Filters compose with AND. The `recipient` filter is PII-gated: passing it without the `analytics.search.recipients` scope returns `403 invalid_scope`, not a silently-dropped filter. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. - `channel` (string, optional) — Filter by channel UUID. Malformed values return `422 validation_failed`. - `status` (string enum, optional) — Filter by current message status. Values are SES event-type names (capitalized): `Send`, `Delivery`, `Bounce`, `Complaint`, `Reject`, `Open`, `Click`, `DeliveryDelay`. Applied as an exact match on the terminal status of each message. - `template` (string, optional) — Filter by exact template name (the SES template slug, not a display name). - `identity` (string, optional) — Filter by sending identity. Accepts a full email address (the local-part is ignored — only the domain matches) or a bare domain. - `recipient` (string, optional, scope: `analytics.search.recipients`) — Filter by exact recipient address (case-sensitive). Requires the `analytics.search.recipients` scope — calls without that scope return `403 invalid_scope`. ## Example request ```bash curl 'https://api.sendops.dev/v1/messages' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Cursor-paginated list of messages Content type: `application/json` ```json { "data": [ { "id": "string", "channel": "string", "template": "string", "from": "string", "to": "string", "subject": "string", "sent_at": "2026-05-17T20:00:00Z", "status": "string", "last_event_at": "2026-05-17T20:00:00Z" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Get one message Source: https://developers.sendops.dev/api-reference/endpoints/messages/get-message Section: Messages > Returns the aggregated detail for a single message (the same `V1Message` projection as the list endpoint). Lookup is by the SES-issued message id, not an internal UUID. Cross-org lookups and unknown ids both return `404 not_found` — no information leak about whether a message exists in another org. Detail lookups go further back than your plan's analytics-retention window: up to 365 days of message detail is kept regardless of plan tier. Very old ids will eventually 404 once they fall outside that window. # Get one message `GET /v1/messages/{id}` - Authentication: required (Bearer token) - Required scope: `messages.view` Returns the aggregated detail for a single message (the same `V1Message` projection as the list endpoint). Lookup is by the SES-issued message id, not an internal UUID. Cross-org lookups and unknown ids both return `404 not_found` — no information leak about whether a message exists in another org. Detail lookups go further back than your plan's analytics-retention window: up to 365 days of message detail is kept regardless of plan tier. Very old ids will eventually 404 once they fall outside that window. ## Path parameters - `id` (string, required) — SES message id (the SES-issued string, not a UUID). ## Example request ```bash curl 'https://api.sendops.dev/v1/messages/string' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Single-message detail Content type: `application/json` ```json { "id": "string", "channel": "string", "template": "string", "from": "string", "to": "string", "subject": "string", "sent_at": "2026-05-17T20:00:00Z", "status": "string", "last_event_at": "2026-05-17T20:00:00Z" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List the SES event timeline for one message Source: https://developers.sendops.dev/api-reference/endpoints/messages/get-message-events Section: Messages > Returns the full SES event timeline for one message (`Send` → `Delivery`/`Bounce`/`Reject`/`DeliveryDelay`, then `Open` and `Click` events as recipients interact). Events are sorted ascending by `occurred_at`. The response is not paginated — a typical message has fewer than a dozen events. Each event carries type-specific `metadata` (omitted on `Send`): | Type | metadata keys | |------------------|---------------| | `Bounce` | `bounce_type`, `bounce_sub_type`, `smtp_response` | | `Complaint` | `complaint_feedback_type` | | `Delivery` | `smtp_response`, `processing_time_ms` | | `DeliveryDelay` | `delay_type` | | `Reject` | `reject_reason` | | `Open` | `ip_address`, `user_agent` | | `Click` | `click_url`, `ip_address`, `user_agent` | A message with zero stored events — either unknown to the platform or belonging to another org — returns `404 not_found`. Same retention caveat as `getMessage`: events are kept for up to 365 days. # List the SES event timeline for one message `GET /v1/messages/{id}/events` - Authentication: required (Bearer token) - Required scope: `messages.view` Returns the full SES event timeline for one message (`Send` → `Delivery`/`Bounce`/`Reject`/`DeliveryDelay`, then `Open` and `Click` events as recipients interact). Events are sorted ascending by `occurred_at`. The response is not paginated — a typical message has fewer than a dozen events. Each event carries type-specific `metadata` (omitted on `Send`): | Type | metadata keys | |------------------|---------------| | `Bounce` | `bounce_type`, `bounce_sub_type`, `smtp_response` | | `Complaint` | `complaint_feedback_type` | | `Delivery` | `smtp_response`, `processing_time_ms` | | `DeliveryDelay` | `delay_type` | | `Reject` | `reject_reason` | | `Open` | `ip_address`, `user_agent` | | `Click` | `click_url`, `ip_address`, `user_agent` | A message with zero stored events — either unknown to the platform or belonging to another org — returns `404 not_found`. Same retention caveat as `getMessage`: events are kept for up to 365 days. ## Path parameters - `id` (string, required) — SES message id. ## Example request ```bash curl 'https://api.sendops.dev/v1/messages/string/events' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Chronological event list (ascending) Content type: `application/json` ```json { "data": [ { "type": "string", "occurred_at": "2026-05-17T20:00:00Z", "metadata": {} } ] } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List messages sent to one recipient (PII-gated) Source: https://developers.sendops.dev/api-reference/endpoints/messages/list-messages-by-recipient Section: Messages > Returns one cursor-paginated page of messages addressed to a specific recipient — the same shape as `listMessages`, but with the recipient pinned from the URL path. This route is gated solely on the `analytics.search.recipients` scope (the broader `messages.view` is not required) so you can grant audit-style recipient lookup without exposing the full message index. Match is case-sensitive on the stored recipient address. URL-encode `@` as `%40` (e.g. `user%40example.com`). Unknown recipients return `200` with `data: []` — never `404`. Same 30-day default window and plan-retention behavior as `listMessages`. # List messages sent to one recipient (PII-gated) `GET /v1/recipients/{email}/messages` - Authentication: required (Bearer token) - Required scope: `analytics.search.recipients` Returns one cursor-paginated page of messages addressed to a specific recipient — the same shape as `listMessages`, but with the recipient pinned from the URL path. This route is gated solely on the `analytics.search.recipients` scope (the broader `messages.view` is not required) so you can grant audit-style recipient lookup without exposing the full message index. Match is case-sensitive on the stored recipient address. URL-encode `@` as `%40` (e.g. `user%40example.com`). Unknown recipients return `200` with `data: []` — never `404`. Same 30-day default window and plan-retention behavior as `listMessages`. ## Path parameters - `email` (string, required) — Recipient address. Percent-encode `@` as `%40`. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. ## Example request ```bash curl 'https://api.sendops.dev/v1/recipients/user%40example.com/messages' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Cursor-paginated list of messages Content type: `application/json` ```json { "data": [ { "id": "string", "channel": "string", "template": "string", "from": "string", "to": "string", "subject": "string", "sent_at": "2026-05-17T20:00:00Z", "status": "string", "last_event_at": "2026-05-17T20:00:00Z" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Deliverability time series Source: https://developers.sendops.dev/api-reference/endpoints/reports/get-deliverability-report Section: Reports > Time-bucketed counts of `sent`, `delivered`, `bounced`, `complained`, and `suppressed`, with a per-bucket `delivery_rate`. Numbers match what you see in the SendOps dashboard. Buckets are aligned to UTC (day = midnight UTC; week = Monday 00:00 UTC; month = first of month 00:00 UTC). Empty periods are **not zero-filled** — buckets with no underlying events are omitted entirely. Formula: `delivery_rate = delivered / sent`, a ratio in `[0, 1]`. When `sent` is zero, `delivery_rate` is `0.0` (never `null`). `suppressed` counts SES `Reject` events — sends SES dropped against the account-level suppression list — not the size of your suppression table. Default window is the trailing 30 days. A `from` older than your plan's retention returns `403 plan_retention_exceeded` with a `retention_days` extension. # Deliverability time series `GET /v1/reports/deliverability` - Authentication: required (Bearer token) - Required scope: `reports.view` Time-bucketed counts of `sent`, `delivered`, `bounced`, `complained`, and `suppressed`, with a per-bucket `delivery_rate`. Numbers match what you see in the SendOps dashboard. Buckets are aligned to UTC (day = midnight UTC; week = Monday 00:00 UTC; month = first of month 00:00 UTC). Empty periods are **not zero-filled** — buckets with no underlying events are omitted entirely. Formula: `delivery_rate = delivered / sent`, a ratio in `[0, 1]`. When `sent` is zero, `delivery_rate` is `0.0` (never `null`). `suppressed` counts SES `Reject` events — sends SES dropped against the account-level suppression list — not the size of your suppression table. Default window is the trailing 30 days. A `from` older than your plan's retention returns `403 plan_retention_exceeded` with a `retention_days` extension. ## Query parameters - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. - `group_by` (string enum, optional) — Time-bucket size for the report. UTC-aligned: `day` = midnight UTC; `week` = Monday 00:00 UTC (ISO week); `month` = first of month 00:00 UTC. Buckets with no underlying events are omitted, not zero-filled. - `channel` (string, optional) — Filter by channel UUID. Malformed values return `422 validation_failed`. - `identity` (string, optional) — Filter by sending identity. Accepts a full email address (the local-part is ignored — only the domain matches) or a bare domain. ## Example request ```bash curl 'https://api.sendops.dev/v1/reports/deliverability' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Deliverability buckets, oldest-first Content type: `application/json` ```json { "data": [ { "period": "2026-05-17T20:00:00Z", "sent": 0, "delivered": 0, "bounced": 0, "complained": 0, "suppressed": 0, "delivery_rate": 0 } ] } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Engagement time series Source: https://developers.sendops.dev/api-reference/endpoints/reports/get-engagement-report Section: Reports > Time-bucketed open and click activity. Each bucket emits raw `sent`, `delivered`, total `opens`/`clicks`, and `unique_opens`/`unique_clicks` (distinct recipients who acted within the bucket). Rate formulas: - `open_rate = unique_opens / delivered` - `click_rate = unique_clicks / delivered` Both denominators are `delivered`, not `sent`, and the numerators are **unique** counts. Ratios are in `[0, 1]`; zero `delivered` yields `0.0`. Buckets align to UTC the same way as deliverability and empty buckets are not zero-filled. Caveat: for `week` and `month` buckets, a recipient who engages on more than one day inside the bucket may be counted more than once in `unique_opens`/`unique_clicks`. This matches the SendOps dashboard. Default window is the trailing 30 days; plan retention applies. # Engagement time series `GET /v1/reports/engagement` - Authentication: required (Bearer token) - Required scope: `reports.view` Time-bucketed open and click activity. Each bucket emits raw `sent`, `delivered`, total `opens`/`clicks`, and `unique_opens`/`unique_clicks` (distinct recipients who acted within the bucket). Rate formulas: - `open_rate = unique_opens / delivered` - `click_rate = unique_clicks / delivered` Both denominators are `delivered`, not `sent`, and the numerators are **unique** counts. Ratios are in `[0, 1]`; zero `delivered` yields `0.0`. Buckets align to UTC the same way as deliverability and empty buckets are not zero-filled. Caveat: for `week` and `month` buckets, a recipient who engages on more than one day inside the bucket may be counted more than once in `unique_opens`/`unique_clicks`. This matches the SendOps dashboard. Default window is the trailing 30 days; plan retention applies. ## Query parameters - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. - `group_by` (string enum, optional) — Time-bucket size for the report. UTC-aligned: `day` = midnight UTC; `week` = Monday 00:00 UTC (ISO week); `month` = first of month 00:00 UTC. Buckets with no underlying events are omitted, not zero-filled. - `channel` (string, optional) — Filter by channel UUID. Malformed values return `422 validation_failed`. - `identity` (string, optional) — Filter by sending identity. Accepts a full email address (the local-part is ignored — only the domain matches) or a bare domain. ## Example request ```bash curl 'https://api.sendops.dev/v1/reports/engagement' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Engagement buckets, oldest-first Content type: `application/json` ```json { "data": [ { "period": "2026-05-17T20:00:00Z", "sent": 0, "delivered": 0, "opens": 0, "unique_opens": 0, "clicks": 0, "unique_clicks": 0, "open_rate": 0, "click_rate": 0 } ] } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Per-template engagement rollup Source: https://developers.sendops.dev/api-reference/endpoints/reports/get-template-performance-report Section: Reports > One row per template aggregating activity across the entire query window — no time bucketing. Use this to spot templates that bounce more, complain more, or under-engage compared to peers. Rows are sorted by `sent` descending, tie-broken by `template` ascending. Sends that aren't tied to a named template (raw `SendEmail` calls without a `Template` parameter) are excluded so they don't dominate the ranking. Rate formulas (note the **asymmetric** denominators, matching the dashboard): - `bounce_rate = bounced / sent` - `complaint_rate = complained / sent` - `open_rate = unique_opens / delivered` - `click_rate = unique_clicks / delivered` All ratios are in `[0, 1]`. Zero denominators yield `0.0`. The `template` field is the SES template name (the slug — the value SES received in the `Template` parameter), not a UI display name. Default window is the trailing 30 days; plan retention applies. No pagination — the response holds every template the org used in the window. # Per-template engagement rollup `GET /v1/reports/template-performance` - Authentication: required (Bearer token) - Required scope: `reports.view` One row per template aggregating activity across the entire query window — no time bucketing. Use this to spot templates that bounce more, complain more, or under-engage compared to peers. Rows are sorted by `sent` descending, tie-broken by `template` ascending. Sends that aren't tied to a named template (raw `SendEmail` calls without a `Template` parameter) are excluded so they don't dominate the ranking. Rate formulas (note the **asymmetric** denominators, matching the dashboard): - `bounce_rate = bounced / sent` - `complaint_rate = complained / sent` - `open_rate = unique_opens / delivered` - `click_rate = unique_clicks / delivered` All ratios are in `[0, 1]`. Zero denominators yield `0.0`. The `template` field is the SES template name (the slug — the value SES received in the `Template` parameter), not a UI display name. Default window is the trailing 30 days; plan retention applies. No pagination — the response holds every template the org used in the window. ## Query parameters - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. - `channel` (string, optional) — Filter by channel UUID. Malformed values return `422 validation_failed`. - `identity` (string, optional) — Filter by sending identity. Accepts a full email address (the local-part is ignored — only the domain matches) or a bare domain. - `template` (string, optional) — Case-insensitive exact match on template name. Useful when you only want one template's row. A non-matching value returns `200` with `data: []`, not `404`. ## Example request ```bash curl 'https://api.sendops.dev/v1/reports/template-performance' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Per-template rows, sorted by sends descending Content type: `application/json` ```json { "data": [ { "template": "string", "sent": 0, "delivered": 0, "opens": 0, "unique_opens": 0, "clicks": 0, "unique_clicks": 0, "bounced": 0, "complained": 0, "bounce_rate": 0, "complaint_rate": 0, "open_rate": 0, "click_rate": 0 } ] } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List suppressed recipients Source: https://developers.sendops.dev/api-reference/endpoints/suppressions/list-suppressions Section: Suppressions > Returns the calling org's AWS SES account-level suppression list. Results are read from SES through a short server-side cache (up to a minute), so newly-added suppressions may take that long to appear. SES populates this list automatically when bounce/complaint feedback arrives; the API is read-only — this endpoint does not add or remove entries. The `source` field is derived from `reason`: entries with reason `bounce` or `complaint` are reported as `source: ses_event` (the platform's event pipeline created them), and everything else is reported as `source: manual` (added directly via the AWS console, CLI, or admin tooling). It is not based on stored provenance — SES does not carry that metadata. Sorting is `suppressed_at` descending. When the `email` filter is set the endpoint switches to full-scan mode: it returns every match in a single response and ignores `limit`/`cursor`/`has_more`. Cursors are opaque base64 — stale or malformed cursors fall back to page 1 silently instead of erroring. # List suppressed recipients `GET /v1/suppressions` - Authentication: required (Bearer token) - Required scope: `suppressions.view` Returns the calling org's AWS SES account-level suppression list. Results are read from SES through a short server-side cache (up to a minute), so newly-added suppressions may take that long to appear. SES populates this list automatically when bounce/complaint feedback arrives; the API is read-only — this endpoint does not add or remove entries. The `source` field is derived from `reason`: entries with reason `bounce` or `complaint` are reported as `source: ses_event` (the platform's event pipeline created them), and everything else is reported as `source: manual` (added directly via the AWS console, CLI, or admin tooling). It is not based on stored provenance — SES does not carry that metadata. Sorting is `suppressed_at` descending. When the `email` filter is set the endpoint switches to full-scan mode: it returns every match in a single response and ignores `limit`/`cursor`/`has_more`. Cursors are opaque base64 — stale or malformed cursors fall back to page 1 silently instead of erroring. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. - `from` (string, optional) — Window start (RFC 3339). Defaults to 30 days ago. - `to` (string, optional) — Window end (RFC 3339). Defaults to now. - `reason` (string enum, optional) — Filter by suppression reason. Case-insensitive on input; lower-cased on output. - `email` (string, optional) — Case-insensitive substring match on the suppressed address. When set, `limit`/`cursor` are ignored and every match is returned in one response. ## Example request ```bash curl 'https://api.sendops.dev/v1/suppressions' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Cursor-paginated list of suppressions, newest-first Content type: `application/json` ```json { "data": [ { "email": "user@example.com", "reason": "bounce", "suppressed_at": "2026-05-17T20:00:00Z", "source": "ses_event" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Look up one suppressed recipient Source: https://developers.sendops.dev/api-reference/endpoints/suppressions/get-suppression Section: Suppressions > Returns the suppression record for an exact email address, or `404 not_found` when the address is not on your SES suppression list. Match is case-insensitive, but the returned `email` preserves SES's stored casing. Newly-added suppressions can take up to a minute to appear here. Percent-encode `@` as `%40` (e.g. `user%40example.com`). # Look up one suppressed recipient `GET /v1/suppressions/{email}` - Authentication: required (Bearer token) - Required scope: `suppressions.view` Returns the suppression record for an exact email address, or `404 not_found` when the address is not on your SES suppression list. Match is case-insensitive, but the returned `email` preserves SES's stored casing. Newly-added suppressions can take up to a minute to appear here. Percent-encode `@` as `%40` (e.g. `user%40example.com`). ## Path parameters - `email` (string, required) — Recipient address. Percent-encode `@` as `%40`. Matched case-insensitively. ## Example request ```bash curl 'https://api.sendops.dev/v1/suppressions/user%40example.com' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — The suppression record Content type: `application/json` ```json { "email": "user@example.com", "reason": "bounce", "suppressed_at": "2026-05-17T20:00:00Z", "source": "ses_event" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List undeliverable recipients (permanent failures + exclusions) Source: https://developers.sendops.dev/api-reference/endpoints/undeliverable/list-undeliverable Section: Undeliverable > Returns recipients with permanent-failure events (Permanent bounces, Complaints, or Rejects) in your retention window — plus operator-cleared addresses as tombstones (`status: excluded`) so polling callers can keep their own suppression list in sync. ### Classification rules (stable across v1) A recipient appears on the list with `status: "listed"` when SendOps has ingested any of the following events for that address in the org's retention window AND no operator exclusion with `excluded_at` greater than the address's `last_seen_at` is in effect: | Event | Condition | Reason | | --------- | -------------------------------------- | ------------------ | | Bounce | `bounceType = Permanent` (any subtype) | `permanent_bounce` | | Complaint | any | `complaint` | | Reject | any | `rejected` | Transient bounces, DeliveryDelay, Send/Delivery/Open/Click events never qualify. Refining this classification is a v2 break — callers may write their suppression logic against the rules above and rely on them. ### Polling with `?since=` Callers maintaining their own suppression list should poll with `?since=`, anchored on the largest `last_changed_at` from their most-recent processed batch. The endpoint returns every row whose `last_changed_at >= since`, including: - newly-listed addresses (recent permanent failures) - re-listed addresses (operator-cleared but bounced again) - excluded tombstones (operator decided to allow this address) Apply rows by `status`: add `listed` rows to your local suppression list, remove `excluded` rows from it. The since boundary is inclusive on the second so a one-row overlap is possible — operations are idempotent so this is safe. ### Snapshot vs sync default With no `since`, the response defaults to `status: "listed"` only — a clean current-state snapshot. Pass `?since=...` to receive both listed and excluded rows. The discrimination is keyed off whether `since` is set; explicit `?status=` overrides the default. ### Retention caveat The listed portion is bounded by your plan's retention window. Exclusion tombstones live in durable storage and surface regardless of how long ago the address last bounced — a caller skipping a poll window will still see the tombstone when they next call with `?since=...`. Cursors are opaque base64 — stale or malformed cursors fall back to page 1 silently instead of erroring. The cursor paginates one snapshot; `?since=` is the real polling mechanism. # List undeliverable recipients (permanent failures + exclusions) `GET /v1/undeliverable` - Authentication: required (Bearer token) - Required scope: `undeliverable.view` Returns recipients with permanent-failure events (Permanent bounces, Complaints, or Rejects) in your retention window — plus operator-cleared addresses as tombstones (`status: excluded`) so polling callers can keep their own suppression list in sync. ### Classification rules (stable across v1) A recipient appears on the list with `status: "listed"` when SendOps has ingested any of the following events for that address in the org's retention window AND no operator exclusion with `excluded_at` greater than the address's `last_seen_at` is in effect: | Event | Condition | Reason | | --------- | -------------------------------------- | ------------------ | | Bounce | `bounceType = Permanent` (any subtype) | `permanent_bounce` | | Complaint | any | `complaint` | | Reject | any | `rejected` | Transient bounces, DeliveryDelay, Send/Delivery/Open/Click events never qualify. Refining this classification is a v2 break — callers may write their suppression logic against the rules above and rely on them. ### Polling with `?since=` Callers maintaining their own suppression list should poll with `?since=`, anchored on the largest `last_changed_at` from their most-recent processed batch. The endpoint returns every row whose `last_changed_at >= since`, including: - newly-listed addresses (recent permanent failures) - re-listed addresses (operator-cleared but bounced again) - excluded tombstones (operator decided to allow this address) Apply rows by `status`: add `listed` rows to your local suppression list, remove `excluded` rows from it. The since boundary is inclusive on the second so a one-row overlap is possible — operations are idempotent so this is safe. ### Snapshot vs sync default With no `since`, the response defaults to `status: "listed"` only — a clean current-state snapshot. Pass `?since=...` to receive both listed and excluded rows. The discrimination is keyed off whether `since` is set; explicit `?status=` overrides the default. ### Retention caveat The listed portion is bounded by your plan's retention window. Exclusion tombstones live in durable storage and surface regardless of how long ago the address last bounced — a caller skipping a poll window will still see the tombstone when they next call with `?since=...`. Cursors are opaque base64 — stale or malformed cursors fall back to page 1 silently instead of erroring. The cursor paginates one snapshot; `?since=` is the real polling mechanism. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. - `since` (string, optional) — RFC 3339 timestamp. Returns only rows whose `last_changed_at >= since`. Use the largest `last_changed_at` from your most-recent processed batch as the next value. The boundary is inclusive on the second. - `status` (string enum, optional) — Filter by row status. Default depends on `?since=`: when `since` is set, both `listed` and `excluded` are returned (so callers see operator-clearing tombstones); when `since` is absent, only `listed` rows are returned (snapshot mode). Pass `all` to override the snapshot default. - `reason` (string enum, optional) — Filter listed rows by classification reason. Has no effect on excluded tombstones. Accepted values (all six are valid query inputs regardless of whether the corresponding rule is currently enabled for the org — filtering on a disabled rule simply returns no rows): - `permanent_bounce` — locked, always on - `complaint` — locked, always on - `rejected` — locked, always on - `repeated_transient` — configurable (SND-713) - `undetermined` — configurable (SND-713) - `soft_bounce_accumulation` — configurable (SND-713) Callers filtering on a configurable reason should confirm via GET /v1/undeliverable/rules that the rule is enabled before treating an empty page as "no matches" rather than "rule off". - `email` (string, optional) — Case-insensitive substring match on the recipient address. - `min_events` (integer, optional) — Only return listed rows whose `event_count >= min_events`. Useful for callers who want "address that bounced ≥ N times" policies. Default `1` (everything qualifying). Excluded tombstones are dropped when `min_events > 1`. ## Example request ```bash curl 'https://api.sendops.dev/v1/undeliverable' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Paginated list of undeliverable rows Content type: `application/json` ```json { "data": [ { "email": "alice@example.com", "status": "listed", "reason": "permanent_bounce", "subtype": "General", "first_seen_at": "2026-04-02T08:14:00Z", "last_seen_at": "2026-05-11T19:42:00Z", "last_changed_at": "2026-05-11T19:42:00Z", "event_count": 1, "last_diagnostic": "smtp; 550 5.1.1 user unknown", "last_sender_identity": "notifications@acme.example" }, { "email": "bob@example.com", "status": "listed", "reason": "complaint", "subtype": "abuse", "first_seen_at": "2026-05-09T11:02:00Z", "last_seen_at": "2026-05-09T11:02:00Z", "last_changed_at": "2026-05-09T11:02:00Z", "event_count": 1, "last_sender_identity": "marketing@acme.example" }, { "email": "carol@example.com", "status": "listed", "reason": "repeated_transient", "subtype": "General", "first_seen_at": "2026-04-28T07:18:00Z", "last_seen_at": "2026-05-15T22:01:00Z", "last_changed_at": "2026-05-15T22:01:00Z", "event_count": 6, "last_diagnostic": "smtp; 421 4.7.0 try again later", "last_sender_identity": "notifications@acme.example" }, { "email": "dave@example.com", "status": "listed", "reason": "undetermined", "first_seen_at": "2026-05-01T12:30:00Z", "last_seen_at": "2026-05-14T16:55:00Z", "last_changed_at": "2026-05-14T16:55:00Z", "event_count": 4, "last_sender_identity": "transactional@acme.example" }, { "email": "erin@example.com", "status": "excluded", "last_changed_at": "2026-05-17T09:10:00Z", "excluded_at": "2026-05-17T09:10:00Z", "excluded_by": "ops@acme.example", "exclusion_reason": "Confirmed valid mailbox, customer asked for re-enable" } ], "pagination": { "has_more": false, "next_cursor": null } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Read the active classification rules for the calling org Source: https://developers.sendops.dev/api-reference/endpoints/undeliverable/get-undeliverable-rules Section: Undeliverable > Returns the active rule set the /v1/undeliverable surface applies when classifying recipients (SND-713). Three rules are always-on (locked, cannot be disabled): permanent_bounce, complaint, rejected. Three rules are configurable per org, each with an `events` threshold (N) and `window_days` (M) — an address matches when ≥ N qualifying events have occurred in a rolling M-day window. When an address matches multiple rules, the wire `reason` is the highest-priority match in this order: permanent_bounce > complaint > rejected > repeated_transient > undetermined > soft_bounce_accumulation. Rules are read-only over the public API in v1 — changes are made through the dashboard (audit-logged, granted to owners + admins only). Callers should pin against `version` and re-fetch when the `X-SendOps-Rules-Version` header on /v1/undeliverable changes. # Read the active classification rules for the calling org `GET /v1/undeliverable/rules` - Authentication: required (Bearer token) - Required scope: `undeliverable.view` Returns the active rule set the /v1/undeliverable surface applies when classifying recipients (SND-713). Three rules are always-on (locked, cannot be disabled): permanent_bounce, complaint, rejected. Three rules are configurable per org, each with an `events` threshold (N) and `window_days` (M) — an address matches when ≥ N qualifying events have occurred in a rolling M-day window. When an address matches multiple rules, the wire `reason` is the highest-priority match in this order: permanent_bounce > complaint > rejected > repeated_transient > undetermined > soft_bounce_accumulation. Rules are read-only over the public API in v1 — changes are made through the dashboard (audit-logged, granted to owners + admins only). Callers should pin against `version` and re-fetch when the `X-SendOps-Rules-Version` header on /v1/undeliverable changes. ## Example request ```bash curl 'https://api.sendops.dev/v1/undeliverable/rules' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Active rule set Content type: `application/json` ```json { "profile": "strict", "rules": { "permanent_bounce": { "enabled": true, "locked": true, "events": 0, "window_days": 0 }, "complaint": { "enabled": true, "locked": true, "events": 0, "window_days": 0 }, "rejected": { "enabled": true, "locked": true, "events": 0, "window_days": 0 }, "repeated_transient": { "enabled": true, "locked": true, "events": 0, "window_days": 0 }, "undetermined": { "enabled": true, "locked": true, "events": 0, "window_days": 0 }, "soft_bounce_accumulation": { "enabled": true, "locked": true, "events": 0, "window_days": 0 } }, "version": "string", "updated_at": "2026-05-17T20:00:00Z", "updated_by": "user@example.com" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Org / AWS / SES / plan snapshot Source: https://developers.sendops.dev/api-reference/endpoints/account/get-account Section: Account > Returns a composed snapshot of the calling org's onboarding state: organization metadata, the connected AWS account and live SES send quota, the production-access request status, the CloudFormation template version, and the active plan. The response is cached per-org for 60 seconds (a `Cache-Control: max-age=60` response header advertises this), so repeated polling does not generate extra AWS or DB load. Each top-level field is always present — sub-objects are `null` when the corresponding milestone has not been reached: - `aws` and `send_quota` are `null` until the customer has connected an AWS account, and also `null` if the live SES `GetAccount` call fails (the rest of the response still renders — SES failure degrades gracefully, it does not 5xx). - `production_access` is `null` until the customer has submitted a production-access request. - `cloudformation` is `null` until the customer has applied the SendOps CloudFormation stack. `aws.sandbox` is the live SES sandbox flag (true while SES still has the AWS account in the default sandbox); this is distinct from `production_access.status`, which reflects the SendOps-side request workflow. Use this endpoint to drive onboarding UIs, detect template drift, and gate features on plan tier. # Org / AWS / SES / plan snapshot `GET /v1/account` - Authentication: required (Bearer token) - Required scope: `org.view` Returns a composed snapshot of the calling org's onboarding state: organization metadata, the connected AWS account and live SES send quota, the production-access request status, the CloudFormation template version, and the active plan. The response is cached per-org for 60 seconds (a `Cache-Control: max-age=60` response header advertises this), so repeated polling does not generate extra AWS or DB load. Each top-level field is always present — sub-objects are `null` when the corresponding milestone has not been reached: - `aws` and `send_quota` are `null` until the customer has connected an AWS account, and also `null` if the live SES `GetAccount` call fails (the rest of the response still renders — SES failure degrades gracefully, it does not 5xx). - `production_access` is `null` until the customer has submitted a production-access request. - `cloudformation` is `null` until the customer has applied the SendOps CloudFormation stack. `aws.sandbox` is the live SES sandbox flag (true while SES still has the AWS account in the default sandbox); this is distinct from `production_access.status`, which reflects the SendOps-side request workflow. Use this endpoint to drive onboarding UIs, detect template drift, and gate features on plan tier. ## Example request ```bash curl 'https://api.sendops.dev/v1/account' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Composed account snapshot Content type: `application/json` ```json { "org": { "id": "00000000-0000-0000-0000-000000000000", "name": "string", "slug": "string" }, "aws": { "account_id": "string", "region": "string", "sandbox": true }, "send_quota": { "max_24_hour": 0, "max_send_rate": 0, "sent_last_24_hours": 0 }, "production_access": { "status": "pending", "granted_at": "2026-05-17T20:00:00Z" }, "cloudformation": { "stack_name": "string", "template_version": 0, "latest_version": 0, "up_to_date": true, "drift_status": "string" }, "plan": { "tier": "free", "retention_days": 0, "api_rate_limit_per_minute": 0 } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List email templates synced from GitHub Source: https://developers.sendops.dev/api-reference/endpoints/entities/list-templates Section: Templates > Returns the org's email templates, sourced from the GitHub repo declared in your `sendops.json` manifest. This endpoint is read-only — templates are managed by committing changes to the repo. Sorted newest-first. `version` is the short (7-char) Git commit SHA of the most recent sync. # List email templates synced from GitHub `GET /v1/templates` - Authentication: required (Bearer token) - Required scope: `templates.view` Returns the org's email templates, sourced from the GitHub repo declared in your `sendops.json` manifest. This endpoint is read-only — templates are managed by committing changes to the repo. Sorted newest-first. `version` is the short (7-char) Git commit SHA of the most recent sync. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. ## Example request ```bash curl 'https://api.sendops.dev/v1/templates' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Templates, newest-first Content type: `application/json` ```json { "data": [ { "slug": "string", "name": "string", "subject": "string", "channel": "string", "version": "string", "last_modified_at": "2026-05-17T20:00:00Z", "source": "git" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Get one template by slug Source: https://developers.sendops.dev/api-reference/endpoints/entities/get-template Section: Templates > Returns one template by its file name (the slug — e.g. `welcome.html`). Returns `404 not_found` when the slug is unknown in this org. # Get one template by slug `GET /v1/templates/{slug}` - Authentication: required (Bearer token) - Required scope: `templates.view` Returns one template by its file name (the slug — e.g. `welcome.html`). Returns `404 not_found` when the slug is unknown in this org. ## Path parameters - `slug` (string, required) — Template file name (e.g. `welcome.html`). ## Example request ```bash curl 'https://api.sendops.dev/v1/templates/string' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Template detail Content type: `application/json` ```json { "slug": "string", "name": "string", "subject": "string", "channel": "string", "version": "string", "last_modified_at": "2026-05-17T20:00:00Z", "source": "git" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List channels (SES configuration sets) Source: https://developers.sendops.dev/api-reference/endpoints/entities/list-channels Section: Channels > Returns the org's channels — each backed by an AWS SES configuration set with its TLS, tracking, and suppression policy. Identities currently bound to each channel are included in the response so you can see at a glance which addresses send through which channel. Sorted newest-first. # List channels (SES configuration sets) `GET /v1/channels` - Authentication: required (Bearer token) - Required scope: `channels.view` Returns the org's channels — each backed by an AWS SES configuration set with its TLS, tracking, and suppression policy. Identities currently bound to each channel are included in the response so you can see at a glance which addresses send through which channel. Sorted newest-first. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. ## Example request ```bash curl 'https://api.sendops.dev/v1/channels' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Channels, newest-first Content type: `application/json` ```json { "data": [ { "name": "string", "slug": "string", "status": "active", "tracking_domain": "string", "suppression_policy": [ "BOUNCE" ], "identities": [ "string" ], "created_at": "2026-05-17T20:00:00Z" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Get one channel by slug Source: https://developers.sendops.dev/api-reference/endpoints/entities/get-channel Section: Channels > Returns one channel by its slug. The path parameter is named `name` for legacy URL stability, but the expected value is the channel **slug** (the `slug` field from the list response), not the human display name. Returns `404 not_found` when the slug is unknown. # Get one channel by slug `GET /v1/channels/{name}` - Authentication: required (Bearer token) - Required scope: `channels.view` Returns one channel by its slug. The path parameter is named `name` for legacy URL stability, but the expected value is the channel **slug** (the `slug` field from the list response), not the human display name. Returns `404 not_found` when the slug is unknown. ## Path parameters - `name` (string, required) — Channel slug — the `slug` field from `listChannels`, not the display name. ## Example request ```bash curl 'https://api.sendops.dev/v1/channels/string' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Channel detail Content type: `application/json` ```json { "name": "string", "slug": "string", "status": "active", "tracking_domain": "string", "suppression_policy": [ "BOUNCE" ], "identities": [ "string" ], "created_at": "2026-05-17T20:00:00Z" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List tracking domains Source: https://developers.sendops.dev/api-reference/endpoints/entities/list-tracking-domains Section: Tracking Domains > Returns the org's CNAME-based open/click tracking subdomains and the DNS records you need to publish for each. `status` is a coalesced view across DNS-record verification and SES-identity verification: `verified` requires both checks to pass; `failed` covers DNS failures; anything else is `pending`. `verified_at` is the time of the last successful verification and stays `null` until then. This endpoint is read-only — add or remove tracking domains via the SendOps dashboard. Sorted newest-first. # List tracking domains `GET /v1/tracking-domains` - Authentication: required (Bearer token) - Required scope: `tracking.view` Returns the org's CNAME-based open/click tracking subdomains and the DNS records you need to publish for each. `status` is a coalesced view across DNS-record verification and SES-identity verification: `verified` requires both checks to pass; `failed` covers DNS failures; anything else is `pending`. `verified_at` is the time of the last successful verification and stays `null` until then. This endpoint is read-only — add or remove tracking domains via the SendOps dashboard. Sorted newest-first. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. ## Example request ```bash curl 'https://api.sendops.dev/v1/tracking-domains' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Tracking domains, newest-first Content type: `application/json` ```json { "data": [ { "domain": "string", "status": "pending", "ssl_status": "active", "dns_records": [ { "type": "string", "name": "string", "value": "string" } ], "created_at": "2026-05-17T20:00:00Z", "verified_at": "2026-05-17T20:00:00Z" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Get one tracking domain Source: https://developers.sendops.dev/api-reference/endpoints/entities/get-tracking-domain Section: Tracking Domains > Returns one tracking domain by its full subdomain string. Match is case-insensitive. `dns_records` always starts with the tracking subdomain's CNAME (pointing at the SendOps redirect host), followed by the SES DKIM CNAMEs — all of them must be published for verification to succeed. Returns `404 not_found` when the domain is not registered with this org. # Get one tracking domain `GET /v1/tracking-domains/{domain}` - Authentication: required (Bearer token) - Required scope: `tracking.view` Returns one tracking domain by its full subdomain string. Match is case-insensitive. `dns_records` always starts with the tracking subdomain's CNAME (pointing at the SendOps redirect host), followed by the SES DKIM CNAMEs — all of them must be published for verification to succeed. Returns `404 not_found` when the domain is not registered with this org. ## Path parameters - `domain` (string, required) — Tracking subdomain (case-insensitive). ## Example request ```bash curl 'https://api.sendops.dev/v1/tracking-domains/string' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Tracking domain detail Content type: `application/json` ```json { "domain": "string", "status": "pending", "ssl_status": "active", "dns_records": [ { "type": "string", "name": "string", "value": "string" } ], "created_at": "2026-05-17T20:00:00Z", "verified_at": "2026-05-17T20:00:00Z" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # List SES identities (email and domain) Source: https://developers.sendops.dev/api-reference/endpoints/entities/list-identities Section: Identities > Returns the SES identities (email addresses and domains) the org has adopted in SendOps. Only **managed** identities are returned — SES identities visible in your AWS account that have not been adopted in SendOps are excluded. `verification_status` and `dkim_status` today hold the same value (both are exposed so they can diverge in a future revision without breaking clients). Sorted newest-first. # List SES identities (email and domain) `GET /v1/identities` - Authentication: required (Bearer token) - Required scope: `identities.view` Returns the SES identities (email addresses and domains) the org has adopted in SendOps. Only **managed** identities are returned — SES identities visible in your AWS account that have not been adopted in SendOps are excluded. `verification_status` and `dkim_status` today hold the same value (both are exposed so they can diverge in a future revision without breaking clients). Sorted newest-first. ## Query parameters - `limit` (integer, optional) — Page size (1–200). Default 50. - `cursor` (string, optional) — Opaque cursor returned from the previous page. ## Example request ```bash curl 'https://api.sendops.dev/v1/identities' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Identities, newest-first Content type: `application/json` ```json { "data": [ { "identity": "string", "type": "email", "verification_status": "pending", "dkim_status": "pending", "mail_from_domain": "string", "created_at": "2026-05-17T20:00:00Z" } ], "pagination": { "has_more": true, "next_cursor": "string" } } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` --- # Get one identity Source: https://developers.sendops.dev/api-reference/endpoints/entities/get-identity Section: Identities > Returns one identity by its full address. Match is case-insensitive. Percent-encode `@` as `%40` (e.g. `user%40example.com`). Returns `404 not_found` when the identity is not managed by this org. # Get one identity `GET /v1/identities/{identity}` - Authentication: required (Bearer token) - Required scope: `identities.view` Returns one identity by its full address. Match is case-insensitive. Percent-encode `@` as `%40` (e.g. `user%40example.com`). Returns `404 not_found` when the identity is not managed by this org. ## Path parameters - `identity` (string, required) — Email address or domain. Percent-encode `@` as `%40`. Case-insensitive. ## Example request ```bash curl 'https://api.sendops.dev/v1/identities/string' \ -H "Authorization: Bearer $SENDOPS_API_KEY" ``` ## Responses ### 200 — Identity detail Content type: `application/json` ```json { "identity": "string", "type": "email", "verification_status": "pending", "dkim_status": "pending", "mail_from_domain": "string", "created_at": "2026-05-17T20:00:00Z" } ``` ### 401 — Missing, malformed, or unknown API key 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 403 — Key lacks the required scope or plan limit violated 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 404 — Resource not found 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 422 — Query parameter or path value failed validation 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 429 — Per-org rate limit exceeded 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ### 500 — Unexpected server-side failure. The `code` is `internal_error`. The `request_id` field can be quoted to SendOps support to investigate. 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, "retention_days": 0, "scope": "string", "resource": "string", "errors": [ { "field": "string", "reason": "string" } ] } ``` ---