{"components":{"headers":{"Link":{"description":"RFC 5988 Link header with the next-page cursor when more rows exist.\nFormat: `\u003chttps://api.sendops.dev/v1/resource?cursor=...\u003e; rel=\"next\"`.\n","schema":{"type":"string"}},"RequestId":{"description":"Mirrored from request when present; otherwise generated server-side.","schema":{"type":"string"}},"RetryAfter":{"description":"Seconds the client should wait before retrying.","schema":{"type":"integer"}}},"parameters":{"Channel":{"description":"Filter by channel UUID. Malformed values return `422 validation_failed`.","in":"query","name":"channel","schema":{"format":"uuid","type":"string"}},"Cursor":{"description":"Opaque cursor returned from the previous page.","in":"query","name":"cursor","schema":{"type":"string"}},"From":{"description":"Window start (RFC 3339). Defaults to 30 days ago.","in":"query","name":"from","schema":{"format":"date-time","type":"string"}},"GroupBy":{"description":"Time-bucket size for the report. UTC-aligned: `day` = midnight UTC;\n`week` = Monday 00:00 UTC (ISO week); `month` = first of month\n00:00 UTC. Buckets with no underlying events are omitted, not\nzero-filled.\n","in":"query","name":"group_by","schema":{"default":"day","enum":["day","week","month"],"type":"string"}},"Identity":{"description":"Filter by sending identity. Accepts a full email address (the\nlocal-part is ignored — only the domain matches) or a bare domain.\n","in":"query","name":"identity","schema":{"type":"string"}},"Limit":{"description":"Page size (1–200). Default 50.","in":"query","name":"limit","schema":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"To":{"description":"Window end (RFC 3339). Defaults to now.","in":"query","name":"to","schema":{"format":"date-time","type":"string"}}},"responses":{"Forbidden":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Key lacks the required scope or plan limit violated"},"InternalError":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unexpected server-side failure. The `code` is `internal_error`. The\n`request_id` field can be quoted to SendOps support to investigate.\n"},"MessageList":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1MessageList"}}},"description":"Cursor-paginated list of messages","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"NotFound":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Resource not found"},"RateLimited":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Per-org rate limit exceeded","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter"}}},"Unauthenticated":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Missing, malformed, or unknown API key"},"ValidationFailed":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Query parameter or path value failed validation"}},"schemas":{"Health":{"properties":{"commit":{"description":"Build SHA (or `unknown` outside CI builds).","type":"string"},"status":{"example":"ok","type":"string"},"time":{"format":"date-time","type":"string"}},"required":["status","commit","time"],"type":"object"},"PageInfo":{"properties":{"has_more":{"type":"boolean"},"next_cursor":{"nullable":true,"type":"string"}},"required":["has_more"],"type":"object"},"Problem":{"properties":{"code":{"enum":["invalid_key","key_revoked","key_expired","invalid_scope","plan_retention_exceeded","not_found","validation_failed","rate_limited","internal_error"],"type":"string"},"detail":{"type":"string"},"errors":{"description":"Present on `validation_failed` for multi-field errors.","items":{"properties":{"field":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"type":"array"},"request_id":{"type":"string"},"resource":{"description":"Present on `not_found`.","type":"string"},"retention_days":{"description":"Present on `plan_retention_exceeded`.","type":"integer"},"retry_after":{"description":"Present on `rate_limited`.","type":"integer"},"scope":{"description":"Present on `invalid_scope`.","type":"string"},"status":{"type":"integer"},"title":{"type":"string"},"type":{"description":"Stable URI identifying the error class. Switch on this, not on `title`.","format":"uri","type":"string"}},"required":["type","title","status","code"],"type":"object"},"ScopesResponse":{"properties":{"environment":{"enum":["live","test"],"type":"string"},"key_id":{"format":"uuid","type":"string"},"prefix":{"example":"sk_live_abc12345","type":"string"},"scopes":{"items":{"type":"string"},"type":"array"}},"required":["key_id","prefix","environment","scopes"],"type":"object"},"V1Account":{"properties":{"aws":{"description":"Null when no AWS account has been connected, or when the live\nSES `GetAccount` call failed (the rest of the response still\nrenders).\n","nullable":true,"properties":{"account_id":{"description":"Null for older connections where STS hadn't returned the assumed-account ID at connect time.","nullable":true,"type":"string"},"region":{"type":"string"},"sandbox":{"description":"Live SES sandbox flag for the connected account. `true`\nwhile SES still has the account in the default sandbox,\n`false` once AWS has granted production access. Distinct\nfrom `production_access.status`, which tracks the\nSendOps-side request workflow.\n","type":"boolean"}},"required":["account_id","region","sandbox"],"type":"object"},"cloudformation":{"description":"Null when no AWS connection exists or the connection has no recorded template version.","nullable":true,"properties":{"drift_status":{"description":"Always `null` in v1 — reserved for future stack-drift detection.","nullable":true,"type":"string"},"latest_version":{"description":"The latest template version baked into this SendOps build.","type":"integer"},"stack_name":{"description":"Always `null` in v1 — reserved for future use.","nullable":true,"type":"string"},"template_version":{"description":"The CloudFormation template version the customer has applied.","type":"integer"},"up_to_date":{"description":"`template_version \u003e= latest_version`.","type":"boolean"}},"required":["stack_name","template_version","latest_version","up_to_date","drift_status"],"type":"object"},"org":{"properties":{"id":{"format":"uuid","type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"type":"object"},"plan":{"properties":{"api_rate_limit_per_minute":{"description":"Null means no throttle is configured for this plan.","nullable":true,"type":"integer"},"retention_days":{"description":"Maximum age in days for analytics queries (messages, reports) on this plan.","type":"integer"},"tier":{"description":"`admin`, `sponsored`, and `demo` are internal plan tiers\nbut can appear in this response for orgs on those tiers.\n","enum":["free","team","business","admin","sponsored","demo"],"type":"string"}},"required":["tier","retention_days","api_rate_limit_per_minute"],"type":"object"},"production_access":{"description":"Null until the org has submitted a production-access request.","nullable":true,"properties":{"granted_at":{"description":"Non-null only when `status == \"granted\"`.","format":"date-time","nullable":true,"type":"string"},"status":{"enum":["pending","under_review","granted","denied","failed"],"type":"string"}},"required":["status","granted_at"],"type":"object"},"send_quota":{"description":"Populated under the same conditions as `aws`. Values are clamped non-negative.","nullable":true,"properties":{"max_24_hour":{"format":"int64","minimum":0,"type":"integer"},"max_send_rate":{"format":"int64","minimum":0,"type":"integer"},"sent_last_24_hours":{"format":"int64","minimum":0,"type":"integer"}},"required":["max_24_hour","max_send_rate","sent_last_24_hours"],"type":"object"}},"required":["org","aws","send_quota","production_access","cloudformation","plan"],"type":"object"},"V1Channel":{"properties":{"created_at":{"description":"When this channel was first registered with SendOps (not the SES config-set creation time).","format":"date-time","type":"string"},"identities":{"description":"Sending identities currently bound to this channel. Items are\nfull email addresses or bare domains (whatever was bound), not\nslugs.\n","items":{"type":"string"},"type":"array"},"name":{"description":"Human display name.","type":"string"},"slug":{"description":"URL-safe key. Use this in `GET /v1/channels/{name}`.","type":"string"},"status":{"enum":["active","discovered","detached","archived","pending"],"type":"string"},"suppression_policy":{"description":"SES suppression reasons applied to this channel. Empty array\nmeans the channel does not opt into account-level suppression\nfor any reason.\n","items":{"enum":["BOUNCE","COMPLAINT"],"type":"string"},"type":"array"},"tracking_domain":{"description":"SES configuration-set `CustomRedirectDomain`. Null when not set.\nMay or may not match a SendOps-managed tracking-domain row.\n","nullable":true,"type":"string"}},"required":["name","slug","status","tracking_domain","suppression_policy","identities","created_at"],"type":"object"},"V1ChannelList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Channel"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1DNSRecord":{"properties":{"name":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}},"required":["type","name","value"],"type":"object"},"V1DeliverabilityPoint":{"properties":{"bounced":{"format":"int64","type":"integer"},"complained":{"format":"int64","type":"integer"},"delivered":{"format":"int64","type":"integer"},"delivery_rate":{"format":"double","type":"number"},"period":{"format":"date-time","type":"string"},"sent":{"format":"int64","type":"integer"},"suppressed":{"format":"int64","type":"integer"}},"required":["period","sent","delivered","bounced","complained","suppressed","delivery_rate"],"type":"object"},"V1DeliverabilityReport":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1DeliverabilityPoint"},"type":"array"}},"required":["data"],"type":"object"},"V1EngagementPoint":{"properties":{"click_rate":{"format":"double","type":"number"},"clicks":{"format":"int64","type":"integer"},"delivered":{"format":"int64","type":"integer"},"open_rate":{"format":"double","type":"number"},"opens":{"format":"int64","type":"integer"},"period":{"format":"date-time","type":"string"},"sent":{"format":"int64","type":"integer"},"unique_clicks":{"format":"int64","type":"integer"},"unique_opens":{"format":"int64","type":"integer"}},"required":["period","sent","delivered","opens","unique_opens","clicks","unique_clicks","open_rate","click_rate"],"type":"object"},"V1EngagementReport":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1EngagementPoint"},"type":"array"}},"required":["data"],"type":"object"},"V1Identity":{"properties":{"created_at":{"format":"date-time","type":"string"},"dkim_status":{"description":"Today this is always the same value as `verification_status`.\nThe fields are exposed separately so they can diverge in a\nfuture revision without breaking clients.\n","enum":["pending","success","failed"],"type":"string"},"identity":{"description":"Full identity — an email address (e.g. `support@example.com`) or a bare domain (e.g. `example.com`).","type":"string"},"mail_from_domain":{"description":"Always `null` in v1 — custom `MAIL FROM` domain is not tracked yet.","nullable":true,"type":"string"},"type":{"description":"Derived from `identity` — `email` when it contains `@`, otherwise `domain`.","enum":["email","domain"],"type":"string"},"verification_status":{"enum":["pending","success","failed"],"type":"string"}},"required":["identity","type","verification_status","dkim_status","mail_from_domain","created_at"],"type":"object"},"V1IdentityList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Identity"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1Message":{"properties":{"channel":{"type":"string"},"from":{"type":"string"},"id":{"type":"string"},"last_event_at":{"format":"date-time","type":"string"},"sent_at":{"format":"date-time","type":"string"},"status":{"type":"string"},"subject":{"type":"string"},"template":{"type":"string"},"to":{"type":"string"}},"required":["id","from","to","sent_at","status"],"type":"object"},"V1MessageEvent":{"properties":{"metadata":{"additionalProperties":true,"type":"object"},"occurred_at":{"format":"date-time","type":"string"},"type":{"type":"string"}},"required":["type","occurred_at"],"type":"object"},"V1MessageEventList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1MessageEvent"},"type":"array"}},"required":["data"],"type":"object"},"V1MessageList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Message"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1Suppression":{"properties":{"email":{"format":"email","type":"string"},"reason":{"enum":["bounce","complaint","manual"],"type":"string"},"source":{"enum":["ses_event","manual"],"type":"string"},"suppressed_at":{"format":"date-time","type":"string"}},"required":["email","reason","suppressed_at","source"],"type":"object"},"V1SuppressionList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Suppression"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1Template":{"properties":{"channel":{"description":"Always `null` in v1 — template-to-channel binding is not tracked\nas a column. Reserved for future use.\n","nullable":true,"type":"string"},"last_modified_at":{"description":"Time the template was last synced — not the upstream commit timestamp.","format":"date-time","type":"string"},"name":{"description":"Same as `slug` today (no separate display-name column).","type":"string"},"slug":{"description":"Template file name (e.g. `welcome.html`).","type":"string"},"source":{"enum":["git"],"type":"string"},"subject":{"description":"Always `null` in v1 — subject is not tracked as a column. Reserved\nfor future use.\n","nullable":true,"type":"string"},"version":{"description":"Short (7-char) Git commit SHA of the last sync. Empty string for\nlegacy rows synced before SHAs were tracked.\n","type":"string"}},"required":["slug","name","subject","channel","version","last_modified_at","source"],"type":"object"},"V1TemplateList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Template"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1TemplatePerformancePoint":{"properties":{"bounce_rate":{"format":"double","type":"number"},"bounced":{"format":"int64","type":"integer"},"click_rate":{"format":"double","type":"number"},"clicks":{"format":"int64","type":"integer"},"complained":{"format":"int64","type":"integer"},"complaint_rate":{"format":"double","type":"number"},"delivered":{"format":"int64","type":"integer"},"open_rate":{"format":"double","type":"number"},"opens":{"format":"int64","type":"integer"},"sent":{"format":"int64","type":"integer"},"template":{"type":"string"},"unique_clicks":{"format":"int64","type":"integer"},"unique_opens":{"format":"int64","type":"integer"}},"required":["template","sent","delivered","opens","unique_opens","clicks","unique_clicks","bounced","complained","bounce_rate","complaint_rate","open_rate","click_rate"],"type":"object"},"V1TemplatePerformanceReport":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1TemplatePerformancePoint"},"type":"array"}},"required":["data"],"type":"object"},"V1TrackingDomain":{"properties":{"created_at":{"format":"date-time","type":"string"},"dns_records":{"description":"DNS records the customer must publish for verification to\nsucceed. The first record is the tracking subdomain's CNAME\n(pointing at the SendOps redirect host); the remaining records\nare the SES DKIM CNAMEs.\n","items":{"$ref":"#/components/schemas/V1DNSRecord"},"type":"array"},"domain":{"description":"Tracking subdomain, e.g. `track.example.com`.","type":"string"},"ssl_status":{"description":"`active` when HTTPS is provisioned for the tracking host; `inactive` for HTTP-only.","enum":["active","inactive"],"type":"string"},"status":{"description":"Coalesced verification state. `verified` requires both\nDNS-record verification and the SES identity to be verified;\n`failed` covers DNS-failed and DNS-invalid sub-states; otherwise\n`pending`.\n","enum":["pending","verified","failed"],"type":"string"},"verified_at":{"description":"Time of the most recent successful verification. `null` until the first verification passes.","format":"date-time","nullable":true,"type":"string"}},"required":["domain","status","ssl_status","dns_records","created_at","verified_at"],"type":"object"},"V1TrackingDomainList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1TrackingDomain"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1Undeliverable":{"description":"One row of the undeliverable list. The shape is polymorphic over\n`status`: listed rows carry per-event aggregate fields; excluded\nrows carry the operator-clearing metadata. `last_changed_at`\nalways reflects the most-recent state transition and is the\nanchor for `?since=` polling.\n","properties":{"email":{"format":"email","type":"string"},"event_count":{"description":"Number of qualifying events in the window. Listed rows only.","format":"int64","minimum":0,"type":"integer"},"excluded_at":{"description":"When the operator cleared the address. Excluded rows only.","format":"date-time","type":"string"},"excluded_by":{"description":"Operator email. Null when the operator has been removed from the org since. Excluded rows only.","format":"email","nullable":true,"type":"string"},"exclusion_reason":{"description":"Free-text operator note recorded at exclusion time. Excluded rows only.","type":"string"},"first_seen_at":{"description":"Oldest qualifying event for this address in the window. Listed rows only.","format":"date-time","type":"string"},"last_changed_at":{"description":"Most-recent state transition. For listed rows this is the\ntimestamp of the most recent qualifying event\n(`last_seen_at`). For excluded rows it is `excluded_at`. Use\nthe largest value from your processed batch as the next\n`?since=` cursor.\n","format":"date-time","type":"string"},"last_diagnostic":{"description":"SMTP response from the most recent event. Useful for triage. Listed rows only.","type":"string"},"last_seen_at":{"description":"Most recent qualifying event for this address. Listed rows only.","format":"date-time","type":"string"},"last_sender_identity":{"description":"SES identity (From address) that last hit this recipient. Listed rows only.","type":"string"},"reason":{"description":"Populated only for `status: \"listed\"`. SND-713 added the three\nwindowed reasons (repeated_transient, undetermined,\nsoft_bounce_accumulation) — older clients that switch on the\noriginal three values should expect new ones and handle them\nas \"currently classified as undeliverable, see rules\".\n","enum":["permanent_bounce","complaint","rejected","repeated_transient","undetermined","soft_bounce_accumulation"],"type":"string"},"status":{"description":"`listed` — the address currently fails our classification\nrules. `excluded` — an operator has cleared the address;\ncallers should remove it from their local suppression list.\n","enum":["listed","excluded"],"type":"string"},"subtype":{"description":"Bounce sub-type (`NoEmail`, `General`, ...) for permanent\nbounces, or the complaint feedback type for complaints.\nEmpty for rejects. Listed rows only.\n","type":"string"}},"required":["email","status","last_changed_at"],"type":"object"},"V1UndeliverableList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/V1Undeliverable"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PageInfo"}},"required":["data","pagination"],"type":"object"},"V1UndeliverableRule":{"description":"One rule in the classification rule set. Locked rules carry\n`locked: true` and no `events`/`window_days` (they fire on any\nqualifying event, lifetime). Configurable rules carry\n`events` (1–100) and `window_days` (1–365); a rule matches when\n≥ events have happened for an address in a rolling window_days\nperiod ending at now.\n","properties":{"enabled":{"description":"Whether the rule contributes to classification. Locked rules\n(`locked: true`) cannot be set to `enabled: false`.\n","type":"boolean"},"events":{"description":"Required event count within the window. Configurable rules only.","maximum":100,"minimum":1,"type":"integer"},"locked":{"description":"True for the three always-on rules (permanent_bounce,\ncomplaint, rejected). Locked rules cannot be disabled — the\nfield is omitted on configurable rules.\n","type":"boolean"},"window_days":{"description":"Rolling window length in days. Configurable rules only.","maximum":365,"minimum":1,"type":"integer"}},"required":["enabled"],"type":"object"},"V1UndeliverableRules":{"description":"Active classification rule set for the calling org. SND-713.\n`profile` is computed server-side from `rules` — the four named\nprofiles are `strict` (locked only), `standard` (locked +\nrepeated_transient, the default), `aggressive` (all configurable\nrules on), and `custom` (the rule body doesn't match any preset).\n`version` is a 6-char hex hash of the canonical rules JSON;\ncallers pin against it to detect drift.\n","properties":{"profile":{"enum":["strict","standard","aggressive","custom"],"type":"string"},"rules":{"properties":{"complaint":{"$ref":"#/components/schemas/V1UndeliverableRule"},"permanent_bounce":{"$ref":"#/components/schemas/V1UndeliverableRule"},"rejected":{"$ref":"#/components/schemas/V1UndeliverableRule"},"repeated_transient":{"$ref":"#/components/schemas/V1UndeliverableRule"},"soft_bounce_accumulation":{"$ref":"#/components/schemas/V1UndeliverableRule"},"undetermined":{"$ref":"#/components/schemas/V1UndeliverableRule"}},"required":["permanent_bounce","complaint","rejected","repeated_transient","undetermined","soft_bounce_accumulation"],"type":"object"},"updated_at":{"description":"When the rules were last changed.","format":"date-time","type":"string"},"updated_by":{"description":"Email of the operator who last changed the rules. Omitted for system-set defaults.","format":"email","type":"string"},"version":{"description":"6-character hex hash over the canonical rules JSON.","type":"string"}},"required":["profile","rules","version"],"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"sk_live_… / sk_test_…","description":"SendOps API keys are issued via the dashboard. Format is\n`sk_live_xxxx...` for production and `sk_test_xxxx...` for the test\nenvironment. Send as `Authorization: Bearer \u003ckey\u003e`.\n","scheme":"bearer","type":"http"}}},"info":{"description":"Read-only Public API for SendOps customers. Host-routed to `api.sendops.dev`\nwith Bearer (`sk_live_` / `sk_test_`) authentication and per-org rate limits.\nErrors follow RFC 7807 (`application/problem+json`). See\nhttps://developers.sendops.dev/api-reference/overview for the long-form documentation.\n","title":"SendOps Public API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/v1/_health":{"get":{"description":"Unauthenticated liveness probe for uptime monitors and status pages.\nReturns a static `{status: \"ok\", commit, time}` payload — a 200 means\nthe API is serving requests, not that every dependency is healthy.\nFor dependency-aware health, watch the platform status page instead.\nThe `commit` field is the deployed build identifier, useful for\nconfirming which version of the API is responding.\n","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}},"description":"Service is up","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"404":{"$ref":"#/components/responses/NotFound"}},"security":[],"summary":"Service liveness probe","tags":["meta"]}},"/v1/_scopes":{"get":{"description":"Returns the scopes attached to the calling API key plus the key's\nidentity (UUID, prefix, and environment `live`/`test`). Use this to\nverify a key authenticates correctly before issuing real traffic, and\nto confirm a recent scope change has taken effect. Any valid key may\ncall this endpoint — no specific scope is required. Scope changes can\ntake up to a few minutes to be reflected here.\n","operationId":"getScopes","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScopesResponse"}}},"description":"Scopes resolved from the cached API key","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"429":{"$ref":"#/components/responses/RateLimited"}},"summary":"Scopes attached to the calling key","tags":["meta"]}},"/v1/account":{"get":{"description":"Returns a composed snapshot of the calling org's onboarding state:\norganization metadata, the connected AWS account and live SES send\nquota, the production-access request status, the CloudFormation\ntemplate version, and the active plan. The response is cached\nper-org for 60 seconds (a `Cache-Control: max-age=60` response\nheader advertises this), so repeated polling does not generate\nextra AWS or DB load.\n\nEach top-level field is always present — sub-objects are `null`\nwhen the corresponding milestone has not been reached:\n\n- `aws` and `send_quota` are `null` until the customer has connected\n  an AWS account, and also `null` if the live SES `GetAccount` call\n  fails (the rest of the response still renders — SES failure\n  degrades gracefully, it does not 5xx).\n- `production_access` is `null` until the customer has submitted a\n  production-access request.\n- `cloudformation` is `null` until the customer has applied the\n  SendOps CloudFormation stack.\n\n`aws.sandbox` is the live SES sandbox flag (true while SES still has\nthe AWS account in the default sandbox); this is distinct from\n`production_access.status`, which reflects the SendOps-side\nrequest workflow. Use this endpoint to drive onboarding UIs,\ndetect template drift, and gate features on plan tier.\n","operationId":"getAccount","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Account"}}},"description":"Composed account snapshot","headers":{"Cache-Control":{"description":"Always `max-age=60` — snapshot is cached per-org for 60 seconds.","schema":{"type":"string"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Org / AWS / SES / plan snapshot","tags":["account"],"x-required-scope":"org.view"}},"/v1/channels":{"get":{"description":"Returns the org's channels — each backed by an AWS SES configuration\nset with its TLS, tracking, and suppression policy. Identities\ncurrently bound to each channel are included in the response so you\ncan see at a glance which addresses send through which channel.\nSorted newest-first.\n","operationId":"listChannels","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1ChannelList"}}},"description":"Channels, newest-first","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List channels (SES configuration sets)","tags":["entities"],"x-required-scope":"channels.view"}},"/v1/channels/{name}":{"get":{"description":"Returns one channel by its slug. The path parameter is named `name`\nfor legacy URL stability, but the expected value is the channel\n**slug** (the `slug` field from the list response), not the human\ndisplay name. Returns `404 not_found` when the slug is unknown.\n","operationId":"getChannel","parameters":[{"description":"Channel slug — the `slug` field from `listChannels`, not the\ndisplay name.\n","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Channel"}}},"description":"Channel detail","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Get one channel by slug","tags":["entities"],"x-required-scope":"channels.view"}},"/v1/identities":{"get":{"description":"Returns the SES identities (email addresses and domains) the org has\nadopted in SendOps. Only **managed** identities are returned — SES\nidentities visible in your AWS account that have not been adopted in\nSendOps are excluded. `verification_status` and `dkim_status` today\nhold the same value (both are exposed so they can diverge in a\nfuture revision without breaking clients). Sorted newest-first.\n","operationId":"listIdentities","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1IdentityList"}}},"description":"Identities, newest-first","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List SES identities (email and domain)","tags":["entities"],"x-required-scope":"identities.view"}},"/v1/identities/{identity}":{"get":{"description":"Returns one identity by its full address. Match is case-insensitive.\nPercent-encode `@` as `%40` (e.g. `user%40example.com`). Returns\n`404 not_found` when the identity is not managed by this org.\n","operationId":"getIdentity","parameters":[{"description":"Email address or domain. Percent-encode `@` as `%40`. Case-insensitive.","in":"path","name":"identity","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Identity"}}},"description":"Identity detail","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Get one identity","tags":["entities"],"x-required-scope":"identities.view"}},"/v1/messages":{"get":{"description":"Returns one cursor-paginated page of message summaries, aggregated to\none row per message. Results are ordered newest-first by `sent_at`.\nWhen `from` and `to` are omitted the window defaults to the trailing\n30 days; a `from` older than your plan's retention is rejected with\n`403 plan_retention_exceeded` (the response carries a `retention_days`\nextension), it is not silently clamped. Use this for dashboards,\nreconciliation, and incremental export — page forward with the\n`next_cursor` from the response (or follow the `Link: \u003c…\u003e; rel=\"next\"`\nheader) until `has_more` is false.\n\nFilters compose with AND. The `recipient` filter is PII-gated: passing\nit without the `analytics.search.recipients` scope returns\n`403 invalid_scope`, not a silently-dropped filter.\n","operationId":"listMessages","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/Channel"},{"description":"Filter by current message status. Values are SES event-type names\n(capitalized): `Send`, `Delivery`, `Bounce`, `Complaint`, `Reject`,\n`Open`, `Click`, `DeliveryDelay`. Applied as an exact match on the\nterminal status of each message.\n","in":"query","name":"status","schema":{"enum":["Send","Delivery","Bounce","Complaint","Reject","Open","Click","DeliveryDelay"],"type":"string"}},{"description":"Filter by exact template name (the SES template slug, not a display name).","in":"query","name":"template","schema":{"type":"string"}},{"$ref":"#/components/parameters/Identity"},{"description":"Filter by exact recipient address (case-sensitive). Requires the\n`analytics.search.recipients` scope — calls without that scope\nreturn `403 invalid_scope`.\n","in":"query","name":"recipient","schema":{"format":"email","type":"string"},"x-required-scope":"analytics.search.recipients"}],"responses":{"200":{"$ref":"#/components/responses/MessageList"},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List messages with cursor pagination","tags":["messages"],"x-required-scope":"messages.view"}},"/v1/messages/{id}":{"get":{"description":"Returns the aggregated detail for a single message (the same\n`V1Message` projection as the list endpoint). Lookup is by the\nSES-issued message id, not an internal UUID. Cross-org lookups and\nunknown ids both return `404 not_found` — no information leak about\nwhether a message exists in another org. Detail lookups go further\nback than your plan's analytics-retention window: up to 365 days of\nmessage detail is kept regardless of plan tier. Very old ids will\neventually 404 once they fall outside that window.\n","operationId":"getMessage","parameters":[{"description":"SES message id (the SES-issued string, not a UUID).","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Message"}}},"description":"Single-message detail","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Get one message","tags":["messages"],"x-required-scope":"messages.view"}},"/v1/messages/{id}/events":{"get":{"description":"Returns the full SES event timeline for one message\n(`Send` → `Delivery`/`Bounce`/`Reject`/`DeliveryDelay`, then `Open` and\n`Click` events as recipients interact). Events are sorted ascending by\n`occurred_at`. The response is not paginated — a typical message has\nfewer than a dozen events.\n\nEach event carries type-specific `metadata` (omitted on `Send`):\n\n| Type             | metadata keys |\n|------------------|---------------|\n| `Bounce`         | `bounce_type`, `bounce_sub_type`, `smtp_response` |\n| `Complaint`      | `complaint_feedback_type` |\n| `Delivery`       | `smtp_response`, `processing_time_ms` |\n| `DeliveryDelay`  | `delay_type` |\n| `Reject`         | `reject_reason` |\n| `Open`           | `ip_address`, `user_agent` |\n| `Click`          | `click_url`, `ip_address`, `user_agent` |\n\nA message with zero stored events — either unknown to the platform or\nbelonging to another org — returns `404 not_found`. Same retention\ncaveat as `getMessage`: events are kept for up to 365 days.\n","operationId":"getMessageEvents","parameters":[{"description":"SES message id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1MessageEventList"}}},"description":"Chronological event list (ascending)","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List the SES event timeline for one message","tags":["messages"],"x-required-scope":"messages.view"}},"/v1/recipients/{email}/messages":{"get":{"description":"Returns one cursor-paginated page of messages addressed to a specific\nrecipient — the same shape as `listMessages`, but with the recipient\npinned from the URL path. This route is gated solely on the\n`analytics.search.recipients` scope (the broader `messages.view` is\nnot required) so you can grant audit-style recipient lookup without\nexposing the full message index.\n\nMatch is case-sensitive on the stored recipient address. URL-encode\n`@` as `%40` (e.g. `user%40example.com`). Unknown recipients return\n`200` with `data: []` — never `404`. Same 30-day default window and\nplan-retention behavior as `listMessages`.\n","operationId":"listMessagesByRecipient","parameters":[{"description":"Recipient address. Percent-encode `@` as `%40`.","in":"path","name":"email","required":true,"schema":{"format":"email","type":"string"}},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"}],"responses":{"200":{"$ref":"#/components/responses/MessageList"},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List messages sent to one recipient (PII-gated)","tags":["messages"],"x-required-scope":"analytics.search.recipients"}},"/v1/reports/deliverability":{"get":{"description":"Time-bucketed counts of `sent`, `delivered`, `bounced`, `complained`,\nand `suppressed`, with a per-bucket `delivery_rate`. Numbers match\nwhat you see in the SendOps dashboard. Buckets are aligned to UTC\n(day = midnight UTC; week = Monday 00:00 UTC; month = first of month\n00:00 UTC). Empty periods are **not zero-filled** — buckets with no\nunderlying events are omitted entirely.\n\nFormula: `delivery_rate = delivered / sent`, a ratio in `[0, 1]`. When\n`sent` is zero, `delivery_rate` is `0.0` (never `null`). `suppressed`\ncounts SES `Reject` events — sends SES dropped against the\naccount-level suppression list — not the size of your suppression\ntable.\n\nDefault window is the trailing 30 days. A `from` older than your\nplan's retention returns `403 plan_retention_exceeded` with a\n`retention_days` extension.\n","operationId":"getDeliverabilityReport","parameters":[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/GroupBy"},{"$ref":"#/components/parameters/Channel"},{"$ref":"#/components/parameters/Identity"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1DeliverabilityReport"}}},"description":"Deliverability buckets, oldest-first","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Deliverability time series","tags":["reports"],"x-required-scope":"reports.view"}},"/v1/reports/engagement":{"get":{"description":"Time-bucketed open and click activity. Each bucket emits raw `sent`,\n`delivered`, total `opens`/`clicks`, and `unique_opens`/`unique_clicks`\n(distinct recipients who acted within the bucket).\n\nRate formulas:\n\n- `open_rate = unique_opens / delivered`\n- `click_rate = unique_clicks / delivered`\n\nBoth denominators are `delivered`, not `sent`, and the numerators are\n**unique** counts. Ratios are in `[0, 1]`; zero `delivered` yields\n`0.0`. Buckets align to UTC the same way as deliverability and empty\nbuckets are not zero-filled.\n\nCaveat: for `week` and `month` buckets, a recipient who engages on\nmore than one day inside the bucket may be counted more than once in\n`unique_opens`/`unique_clicks`. This matches the SendOps dashboard.\nDefault window is the trailing 30 days; plan retention applies.\n","operationId":"getEngagementReport","parameters":[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/GroupBy"},{"$ref":"#/components/parameters/Channel"},{"$ref":"#/components/parameters/Identity"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1EngagementReport"}}},"description":"Engagement buckets, oldest-first","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Engagement time series","tags":["reports"],"x-required-scope":"reports.view"}},"/v1/reports/template-performance":{"get":{"description":"One row per template aggregating activity across the entire query\nwindow — no time bucketing. Use this to spot templates that bounce\nmore, complain more, or under-engage compared to peers. Rows are\nsorted by `sent` descending, tie-broken by `template` ascending. Sends\nthat aren't tied to a named template (raw `SendEmail` calls without a\n`Template` parameter) are excluded so they don't dominate the\nranking.\n\nRate formulas (note the **asymmetric** denominators, matching the\ndashboard):\n\n- `bounce_rate = bounced / sent`\n- `complaint_rate = complained / sent`\n- `open_rate = unique_opens / delivered`\n- `click_rate = unique_clicks / delivered`\n\nAll ratios are in `[0, 1]`. Zero denominators yield `0.0`. The\n`template` field is the SES template name (the slug — the value SES\nreceived in the `Template` parameter), not a UI display name.\n\nDefault window is the trailing 30 days; plan retention applies. No\npagination — the response holds every template the org used in the\nwindow.\n","operationId":"getTemplatePerformanceReport","parameters":[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/Channel"},{"$ref":"#/components/parameters/Identity"},{"description":"Case-insensitive exact match on template name. Useful when you\nonly want one template's row. A non-matching value returns\n`200` with `data: []`, not `404`.\n","in":"query","name":"template","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1TemplatePerformanceReport"}}},"description":"Per-template rows, sorted by sends descending","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Per-template engagement rollup","tags":["reports"],"x-required-scope":"reports.view"}},"/v1/suppressions":{"get":{"description":"Returns the calling org's AWS SES account-level suppression list.\nResults are read from SES through a short server-side cache (up to a\nminute), so newly-added suppressions may take that long to appear.\nSES populates this list automatically when bounce/complaint feedback\narrives; the API is read-only — this endpoint does not add or remove\nentries.\n\nThe `source` field is derived from `reason`: entries with reason\n`bounce` or `complaint` are reported as `source: ses_event` (the\nplatform's event pipeline created them), and everything else is\nreported as `source: manual` (added directly via the AWS console,\nCLI, or admin tooling). It is not based on stored provenance — SES\ndoes not carry that metadata.\n\nSorting is `suppressed_at` descending. When the `email` filter is\nset the endpoint switches to full-scan mode: it returns every match\nin a single response and ignores `limit`/`cursor`/`has_more`.\nCursors are opaque base64 — stale or malformed cursors fall back to\npage 1 silently instead of erroring.\n","operationId":"listSuppressions","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"description":"Filter by suppression reason. Case-insensitive on input; lower-cased on output.","in":"query","name":"reason","schema":{"enum":["bounce","complaint","manual"],"type":"string"}},{"description":"Case-insensitive substring match on the suppressed address. When\nset, `limit`/`cursor` are ignored and every match is returned in\none response.\n","in":"query","name":"email","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1SuppressionList"}}},"description":"Cursor-paginated list of suppressions, newest-first","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List suppressed recipients","tags":["suppressions"],"x-required-scope":"suppressions.view"}},"/v1/suppressions/{email}":{"get":{"description":"Returns the suppression record for an exact email address, or\n`404 not_found` when the address is not on your SES suppression list.\nMatch is case-insensitive, but the returned `email` preserves SES's\nstored casing. Newly-added suppressions can take up to a minute to\nappear here. Percent-encode `@` as `%40` (e.g. `user%40example.com`).\n","operationId":"getSuppression","parameters":[{"description":"Recipient address. Percent-encode `@` as `%40`. Matched case-insensitively.","in":"path","name":"email","required":true,"schema":{"format":"email","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Suppression"}}},"description":"The suppression record","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Look up one suppressed recipient","tags":["suppressions"],"x-required-scope":"suppressions.view"}},"/v1/templates":{"get":{"description":"Returns the org's email templates, sourced from the GitHub repo\ndeclared in your `sendops.json` manifest. This endpoint is read-only\n— templates are managed by committing changes to the repo. Sorted\nnewest-first. `version` is the short (7-char) Git commit SHA of the\nmost recent sync.\n","operationId":"listTemplates","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1TemplateList"}}},"description":"Templates, newest-first","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List email templates synced from GitHub","tags":["entities"],"x-required-scope":"templates.view"}},"/v1/templates/{slug}":{"get":{"description":"Returns one template by its file name (the slug — e.g.\n`welcome.html`). Returns `404 not_found` when the slug is unknown\nin this org.\n","operationId":"getTemplate","parameters":[{"description":"Template file name (e.g. `welcome.html`).","in":"path","name":"slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Template"}}},"description":"Template detail","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Get one template by slug","tags":["entities"],"x-required-scope":"templates.view"}},"/v1/tracking-domains":{"get":{"description":"Returns the org's CNAME-based open/click tracking subdomains and the\nDNS records you need to publish for each. `status` is a coalesced\nview across DNS-record verification and SES-identity verification:\n`verified` requires both checks to pass; `failed` covers DNS\nfailures; anything else is `pending`. `verified_at` is the time of\nthe last successful verification and stays `null` until then. This\nendpoint is read-only — add or remove tracking domains via the\nSendOps dashboard. Sorted newest-first.\n","operationId":"listTrackingDomains","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1TrackingDomainList"}}},"description":"Tracking domains, newest-first","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List tracking domains","tags":["entities"],"x-required-scope":"tracking.view"}},"/v1/tracking-domains/{domain}":{"get":{"description":"Returns one tracking domain by its full subdomain string. Match is\ncase-insensitive. `dns_records` always starts with the tracking\nsubdomain's CNAME (pointing at the SendOps redirect host), followed\nby the SES DKIM CNAMEs — all of them must be published for\nverification to succeed. Returns `404 not_found` when the domain is\nnot registered with this org.\n","operationId":"getTrackingDomain","parameters":[{"description":"Tracking subdomain (case-insensitive).","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1TrackingDomain"}}},"description":"Tracking domain detail","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Get one tracking domain","tags":["entities"],"x-required-scope":"tracking.view"}},"/v1/undeliverable":{"get":{"description":"Returns recipients with permanent-failure events (Permanent\nbounces, Complaints, or Rejects) in your retention window — plus\noperator-cleared addresses as tombstones (`status: excluded`)\nso polling callers can keep their own suppression list in sync.\n\n### Classification rules (stable across v1)\n\nA recipient appears on the list with `status: \"listed\"` when\nSendOps has ingested any of the following events for that\naddress in the org's retention window AND no operator exclusion\nwith `excluded_at` greater than the address's `last_seen_at` is\nin effect:\n\n| Event     | Condition                              | Reason             |\n| --------- | -------------------------------------- | ------------------ |\n| Bounce    | `bounceType = Permanent` (any subtype) | `permanent_bounce` |\n| Complaint | any                                    | `complaint`        |\n| Reject    | any                                    | `rejected`         |\n\nTransient bounces, DeliveryDelay, Send/Delivery/Open/Click\nevents never qualify. Refining this classification is a v2\nbreak — callers may write their suppression logic against the\nrules above and rely on them.\n\n### Polling with `?since=`\n\nCallers maintaining their own suppression list should poll with\n`?since=\u003cRFC 3339\u003e`, anchored on the largest `last_changed_at`\nfrom their most-recent processed batch. The endpoint returns\nevery row whose `last_changed_at \u003e= since`, including:\n\n- newly-listed addresses (recent permanent failures)\n- re-listed addresses (operator-cleared but bounced again)\n- excluded tombstones (operator decided to allow this address)\n\nApply rows by `status`: add `listed` rows to your local\nsuppression list, remove `excluded` rows from it. The since\nboundary is inclusive on the second so a one-row overlap is\npossible — operations are idempotent so this is safe.\n\n### Snapshot vs sync default\n\nWith no `since`, the response defaults to `status: \"listed\"`\nonly — a clean current-state snapshot. Pass `?since=...` to\nreceive both listed and excluded rows. The discrimination is\nkeyed off whether `since` is set; explicit `?status=` overrides\nthe default.\n\n### Retention caveat\n\nThe listed portion is bounded by your plan's retention window.\nExclusion tombstones live in durable storage and surface\nregardless of how long ago the address last bounced — a caller\nskipping a poll window will still see the tombstone when they\nnext call with `?since=...`.\n\nCursors are opaque base64 — stale or malformed cursors fall back\nto page 1 silently instead of erroring. The cursor paginates one\nsnapshot; `?since=` is the real polling mechanism.\n","operationId":"listUndeliverable","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"description":"RFC 3339 timestamp. Returns only rows whose\n`last_changed_at \u003e= since`. Use the largest `last_changed_at`\nfrom your most-recent processed batch as the next value.\nThe boundary is inclusive on the second.\n","in":"query","name":"since","schema":{"format":"date-time","type":"string"}},{"description":"Filter by row status. Default depends on `?since=`: when\n`since` is set, both `listed` and `excluded` are returned\n(so callers see operator-clearing tombstones); when `since`\nis absent, only `listed` rows are returned (snapshot mode).\nPass `all` to override the snapshot default.\n","in":"query","name":"status","schema":{"enum":["listed","excluded","all"],"type":"string"}},{"description":"Filter listed rows by classification reason. Has no effect on\nexcluded tombstones. SND-713: the three windowed reasons\n(`repeated_transient`, `undetermined`, `soft_bounce_accumulation`)\nare configurable per org; callers that filter on them should\nconfirm via GET /v1/undeliverable/rules that the rule is\nenabled before relying on results.\n","in":"query","name":"reason","schema":{"enum":["permanent_bounce","complaint","rejected","repeated_transient","undetermined","soft_bounce_accumulation"],"type":"string"}},{"description":"Case-insensitive substring match on the recipient address.","in":"query","name":"email","schema":{"type":"string"}},{"description":"Only return listed rows whose `event_count \u003e= min_events`.\nUseful for callers who want \"address that bounced ≥ N times\"\npolicies. Default `1` (everything qualifying). Excluded\ntombstones are dropped when `min_events \u003e 1`.\n","in":"query","name":"min_events","schema":{"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1UndeliverableList"}}},"description":"Paginated list of undeliverable rows","headers":{"Link":{"$ref":"#/components/headers/Link"},"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"X-SendOps-Rules-Version":{"description":"Short hash of the active classification rules. Pin your\ncallers against this and re-fetch /v1/undeliverable/rules\nwhen it changes — the set of returned rows reflects the\nrules in force at request time.\n","schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"List undeliverable recipients (permanent failures + exclusions)","tags":["undeliverable"],"x-required-scope":"undeliverable.view"}},"/v1/undeliverable/rules":{"get":{"description":"Returns the active rule set the /v1/undeliverable surface applies\nwhen classifying recipients (SND-713). Three rules are always-on\n(locked, cannot be disabled): permanent_bounce, complaint,\nrejected. Three rules are configurable per org, each with an\n`events` threshold (N) and `window_days` (M) — an address matches\nwhen ≥ N qualifying events have occurred in a rolling M-day window.\n\nWhen an address matches multiple rules, the wire `reason` is the\nhighest-priority match in this order:\npermanent_bounce \u003e complaint \u003e rejected \u003e repeated_transient \u003e\nundetermined \u003e soft_bounce_accumulation.\n\nRules are read-only over the public API in v1 — changes are made\nthrough the dashboard (audit-logged, granted to owners + admins\nonly). Callers should pin against `version` and re-fetch when the\n`X-SendOps-Rules-Version` header on /v1/undeliverable changes.\n","operationId":"getUndeliverableRules","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1UndeliverableRules"}}},"description":"Active rule set","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"},"X-SendOps-Rules-Version":{"description":"Mirrors the `version` field in the body — duplicated as a header so HEAD-style polling stays cheap.","schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"summary":"Read the active classification rules for the calling org","tags":["undeliverable"],"x-required-scope":"undeliverable.view"}}},"security":[{"bearerAuth":[]}],"servers":[{"description":"Production","url":"https://api.sendops.dev"}],"tags":[{"description":"Introspection endpoints (`_health`, `_scopes`).","name":"meta"},{"description":"Message and event lookups.","name":"messages"},{"description":"Time-bucketed deliverability and engagement aggregates.","name":"reports"},{"description":"SES suppression list.","name":"suppressions"},{"description":"SendOps-derived permanent-failure list with operator exclusions.","name":"undeliverable"},{"description":"Composed snapshot of org, AWS, SES, plan, and CloudFormation state.","name":"account"},{"description":"Templates, channels, tracking domains, identities.","name":"entities"}]}