openapi: 3.0.3 info: title: CYFOX Relay API version: "1.0" description: | Email delivery API for SaaS platforms. One **account** is a SaaS platform; it has many **tenants** (its customers). Every message, domain, credential, quota, pause and metric belongs to exactly one tenant, and detection and pausing act on one tenant at a time. Agent Mailbox endpoints use the same `/v1` API and add inbox-scoped receiving, mailbox and agent-reply operations. ## Authentication `Authorization: Bearer cfx__`. * **Account-wide key** (`tenant_id` null) - may act on every tenant of the account, subject to its scopes. * **Tenant-bound key** - may only `send`, `read` and `feedback` for its own tenant. It can never pause, administer, or see another tenant (cross-tenant access answers `404`). Scopes: `send`, `read`, `feedback` (complaints, suppressions), `suspend` (pause/resume), `admin` (everything else; implies all scopes). `suspend` and `admin` require an account-wide key. Operator endpoints under `/admin/v1` use the `X-Admin-Token` header instead. ## Errors ```json { "error": { "code": "quota_exceeded", "message": "daily quota of 5000 recipients exceeded" } } ``` Throttling responses (`429`, `503`) carry `Retry-After` (seconds). ## Webhooks Every event (`accepted`, `held`, `released`, `delivered`, `deferred`, `bounced`, `blocked`, `complained`, `suppressed`, `expired`, `cancelled`, `unsubscribed`, `suspended`, `resumed`, `anomaly_detected`, `auth_failed`, `rejected`) can be pushed to a webhook. Delivery is at-least-once with exponential retry; use the event `id` to de-duplicate. Each request carries: | Header | Meaning | |---|---| | `X-Cyfox-Event` | event type | | `X-Cyfox-Event-Id` | unique, increasing event id | | `X-Cyfox-Delivery-Attempt` | 1-based attempt counter | | `X-Cyfox-Signature` | `t=,v1=` where `v1 = HMAC-SHA256(secret, ".")` | Reject signatures whose `t` is more than 5 minutes old. servers: - url: http://localhost:8080 security: - bearer: [] tags: - name: Mailbox description: Folders, move, delete, batch operations and content downloads - a full mailbox over the API - name: Drafts description: Save drafts and schedule messages for later delivery - name: AI description: Auto-labelling and structured extraction (OpenAI); opt-in per account - name: Sending - name: Tenants - name: Account description: Account settings and dashboard overview - name: Billing description: Usage metering, plans and invoice preview (minor units; no payments) - name: Control description: Pause, resume and release - name: Feedback - name: Webhooks - name: Detection - name: Observability - name: Admin - name: Inboxes description: Agent Mailbox inboxes, threads, messages and inbound configuration - name: Shield description: Agent Shield outbound approvals - name: MCP description: Hosted Model Context Protocol endpoint for Agent Mail paths: /v1/messages: post: tags: [Sending] summary: Send a message description: | Accepts the message for delivery. `202` means durably queued (or held, if the tenant, key, domain or sender is paused - see `held`). Sending is refused with `403` when a pause was configured with `queue_action: reject`, `429` when the tenant's quota or send rate is exceeded. Use `Idempotency-Key` (or `idempotency_key`) to make retries safe. parameters: - in: header name: Idempotency-Key schema: { type: string } requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SendRequest" } responses: "202": description: Accepted content: application/json: schema: { $ref: "#/components/schemas/SendResponse" } "400": { $ref: "#/components/responses/Invalid" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } "429": { $ref: "#/components/responses/Throttled" } "503": { $ref: "#/components/responses/Throttled" } get: tags: [Sending] summary: Search messages parameters: - { in: query, name: tenant_id, schema: { type: string, format: uuid } } - { in: query, name: recipient, schema: { type: string } } - { in: query, name: from, schema: { type: string } } - { in: query, name: status, schema: { type: string } } - { in: query, name: since, schema: { type: string, format: date-time } } - { in: query, name: until, schema: { type: string, format: date-time } } - { in: query, name: before, schema: { type: string, format: date-time } } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Messages with per-status recipient counts } /v1/messages/{id}: get: tags: [Sending] summary: Message with per-recipient delivery state parameters: [{ $ref: "#/components/parameters/Id" }] responses: "200": { description: Message and its deliveries } "404": { $ref: "#/components/responses/NotFound" } /v1/events: get: tags: [Observability] summary: Event log (delivered, bounced, deferred, complained, blocked, ...) parameters: - { in: query, name: type, schema: { type: string } } - { in: query, name: tenant_id, schema: { type: string, format: uuid } } - { in: query, name: message_id, schema: { type: string, format: uuid } } - { in: query, name: recipient, schema: { type: string } } - { in: query, name: before_id, schema: { type: integer, format: int64 } } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Events, newest first } /v1/tenants: post: tags: [Tenants] summary: Create a tenant (a customer of the SaaS) description: Requires `admin`. Omitted quota/rate fields mean unlimited. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TenantCreate" } responses: "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Tenant" } } } } "409": { description: external_id already exists } get: tags: [Tenants] summary: List tenants responses: "200": { description: Tenants visible to the key } /v1/tenants/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Tenants] summary: Get a tenant responses: "200": { description: Tenant, content: { application/json: { schema: { $ref: "#/components/schemas/Tenant" } } } } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Tenants] summary: Change name, status or limits description: Fields that are absent are kept; `null` removes a limit. Requires `admin`. The change is audited with before/after values. requestBody: content: application/json: schema: type: object properties: name: { type: string } status: { type: string, enum: [active, disabled] } quota_daily: { type: integer, nullable: true, minimum: 0 } quota_monthly: { type: integer, nullable: true, minimum: 0 } rate_per_minute: { type: integer, nullable: true, minimum: 0 } rate_per_hour: { type: integer, nullable: true, minimum: 0 } max_recipients_per_message: { type: integer, minimum: 1 } responses: "200": { description: Updated tenant } /v1/tenants/{id}/usage: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Tenants] summary: Usage against quota and rate, with daily history parameters: [{ in: query, name: days, schema: { type: integer, default: 30 } }] responses: "200": { description: Usage } /v1/tenants/{id}/pause: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Control] summary: Pause one tenant description: Shortcut for `POST /v1/suspensions` with `scope_type=tenant`. Requires `suspend`. requestBody: content: application/json: schema: { $ref: "#/components/schemas/PauseRequest" } responses: "201": { description: "Paused (`created: true`)" } "200": { description: Already paused } /v1/tenants/{id}/resume: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Control] summary: Resume a paused tenant after review requestBody: content: application/json: schema: { $ref: "#/components/schemas/ResumeRequest" } responses: "200": { description: Resumed; held mail released or purged } "404": { description: Tenant is not paused } /v1/tenants/{id}/domains: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Tenants] summary: Register a sending domain description: Generates a DKIM key pair and returns the DNS records the customer must publish (SPF, DKIM, DMARC, return-path CNAME). requestBody: required: true content: application/json: schema: type: object required: [name] properties: { name: { type: string, example: shop.example.com } } responses: "201": { description: Domain and DNS records } /v1/domains: get: tags: [Tenants] summary: List domains with DNS records and verification state parameters: [{ in: query, name: tenant_id, schema: { type: string, format: uuid } }] responses: "200": { description: Domains } /v1/domains/{id}/verify: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Tenants] summary: Check DNS now responses: "200": { description: Verification result (`verified` is true when SPF and DKIM are in place) } /v1/domains/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Tenants] summary: Delete a domain responses: "204": { description: Deleted } /v1/domains/{id}/inbound: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Inboxes] summary: Enable or disable inbound mail for a domain description: Requires a verified MX record pointing at the platform MX host. requestBody: content: application/json: schema: type: object properties: { enabled: { type: boolean } } responses: "200": { description: Updated domain } "409": { description: MX record not verified } /v1/tenants/{id}/inboxes: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Inboxes] summary: Create an inbox on a verified, inbound-enabled domain requestBody: required: true content: application/json: schema: type: object required: [domain_id, local_part] properties: domain_id: { type: string, format: uuid } local_part: { type: string, example: support } display_name: { type: string } policy: { type: object, additionalProperties: true } metadata: { type: object, additionalProperties: true } responses: "201": { description: Inbox created } /v1/inboxes: get: tags: [Inboxes] summary: List inboxes parameters: - { in: query, name: tenant_id, schema: { type: string, format: uuid } } - { in: query, name: cursor, schema: { type: string } } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: Inboxes } /v1/inboxes/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Get an inbox responses: "200": { description: Inbox } patch: tags: [Inboxes] summary: Update an inbox (display name, status, policy, metadata) requestBody: content: application/json: schema: type: object properties: display_name: { type: string } status: { type: string, enum: [active, paused] } policy: { type: object, additionalProperties: true } metadata: { type: object, additionalProperties: true } responses: "200": { description: Updated inbox } delete: tags: [Inboxes] summary: Delete an inbox responses: "204": { description: Deleted } /v1/inboxes/{id}/threads: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: List threads in an inbox (most recently active first) parameters: - { in: query, name: cursor, schema: { type: string } } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: Threads } /v1/inboxes/{id}/messages: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Check mail - list messages in an inbox (newest first) parameters: - { in: query, name: folder, schema: { type: string }, description: "inbox, sent, archive, spam, trash, a custom folder, or 'all'. Default: everything except trash." } - { in: query, name: unread, schema: { type: boolean }, description: Only unread inbound messages } - { in: query, name: direction, schema: { type: string, enum: [in, out] } } - { in: query, name: label, schema: { type: string } } - { in: query, name: from, schema: { type: string }, description: Substring match on the sender } - { in: query, name: cursor, schema: { type: string } } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: Messages } post: tags: [Inboxes] summary: Compose and send a new message from the inbox description: > The From address is always the inbox's own address (the shared-domain sender rule); it cannot be overridden by the caller. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/InboxCompose" } responses: "202": { description: Accepted for delivery and recorded in the thread } /v1/threads/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Get a thread responses: "200": { description: Thread } /v1/threads/{id}/messages: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: List messages in a thread responses: "200": { description: Messages } /v1/inbox-messages/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Get an inbox message with its attachments (metadata and extracted text) responses: "200": { description: Message } delete: tags: [Mailbox] summary: Delete a message (moves to trash; deleting from trash removes it for good) parameters: - { in: query, name: permanent, schema: { type: boolean }, description: Skip trash } responses: "200": { description: "{ id, trashed, deleted }" } /v1/inbox-messages/{id}/extract: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [AI] summary: Extract structured data from a message (OpenAI structured outputs) description: > Runs the model over the message using the supplied JSON schema. The body is treated as untrusted data the model can never act on. Requires AI to be enabled on the account (POST is gated by the monthly token cap). Spam or blocked mail is refused. requestBody: required: true content: application/json: schema: type: object required: [schema] properties: schema: type: object description: A JSON Schema object describing the data to extract save: type: boolean description: Persist the result to the message's extracted_data (default false) responses: "200": { description: "{ message_id, extracted_data }" } "403": { description: AI not enabled for this account } "429": { description: Monthly AI token cap reached } "503": { description: AI features not configured on this server } /v1/inboxes/{id}/folders: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Mailbox] summary: List folders with total/unread counts description: System folders (inbox, sent, archive, spam, trash) plus custom folders. responses: "200": { description: Folders } post: tags: [Mailbox] summary: Create a custom folder requestBody: required: true content: application/json: schema: type: object required: [name] properties: { name: { type: string, minLength: 1, maxLength: 64 } } responses: "201": { description: Created } "409": { description: Name exists or is a system folder } /v1/inboxes/{id}/folders/{name}: parameters: - { $ref: "#/components/parameters/Id" } - { in: path, name: name, required: true, schema: { type: string } } patch: tags: [Mailbox] summary: Rename a custom folder (its messages follow) requestBody: required: true content: application/json: schema: type: object required: [name] properties: { name: { type: string } } responses: "200": { description: Renamed } delete: tags: [Mailbox] summary: Delete a custom folder parameters: - { in: query, name: move_to, schema: { type: string }, description: Folder that receives remaining messages; required if the folder is not empty } responses: "200": { description: Deleted } "409": { description: Folder not empty } /v1/inboxes/{id}/folders/{name}/empty: parameters: - { $ref: "#/components/parameters/Id" } - { in: path, name: name, required: true, schema: { type: string, enum: [trash, spam] } } post: tags: [Mailbox] summary: Permanently delete everything in trash or spam responses: "200": { description: Result counts } /v1/inbox-messages/{id}/move: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Mailbox] summary: Move a message to a folder requestBody: required: true content: application/json: schema: type: object required: [folder] properties: { folder: { type: string } } responses: "200": { description: Updated message } "400": { description: Unknown folder, or destination is "sent" } /v1/inboxes/{id}/batch: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Mailbox] summary: Apply one action to up to 200 messages requestBody: required: true content: application/json: schema: type: object required: [action, ids] properties: action: { type: string, enum: [move, delete, read, unread] } ids: { type: array, maxItems: 200, items: { type: string, format: uuid } } folder: { type: string, description: Destination for action=move } permanent: { type: boolean, description: Skip trash for action=delete } responses: "200": description: Counts (ids outside the inbox are reported as missing) content: application/json: schema: type: object properties: moved: { type: integer } deleted: { type: integer } missing: { type: integer } /v1/inboxes/{id}/drafts: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Drafts] summary: List drafts in an inbox parameters: - { in: query, name: cursor, schema: { type: string } } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: "{ drafts: [...] }" } post: tags: [Drafts] summary: Create a draft (optionally scheduled via send_at) requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/DraftCompose" } responses: "201": { description: "{ draft }" } "400": { description: send_at is not in the future, or reply_to_id is invalid } /v1/drafts/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Drafts] summary: Get a draft responses: "200": { description: "{ draft }" } "404": { description: Not a draft in a reachable inbox } patch: tags: [Drafts] summary: Replace a draft's content (and optional schedule) requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/DraftCompose" } responses: "200": { description: "{ draft }" } delete: tags: [Drafts] summary: Permanently delete a draft responses: "200": { description: "{ deleted: true }" } /v1/drafts/{id}/send: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Drafts] summary: Send a draft now description: Runs through the same outbound policy as a direct send; may be held for approval. responses: "202": { description: Sent (or held for approval); the draft is removed } /v1/drafts/{id}/schedule: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Drafts] summary: Schedule an existing draft for later delivery requestBody: required: true content: application/json: schema: type: object required: [send_at] properties: { send_at: { type: string, format: date-time } } responses: "200": { description: "{ id, scheduled_at }" } "400": { description: send_at must be a future timestamp } /v1/drafts/{id}/cancel: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Drafts] summary: Cancel a draft's schedule (keeps it as a plain draft) responses: "200": { description: "{ id, scheduled_at: null }" } /v1/inbox-messages/{id}/raw: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Mailbox] summary: Download the original RFC 5322 source (message/rfc822) responses: "200": { description: The .eml source } /v1/inbox-messages/{id}/html: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Mailbox] summary: Fetch the HTML body (returned as JSON, never rendered) responses: "200": { description: "{ id, html }" } /v1/inbox-attachments/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Mailbox] summary: Download an attachment responses: "200": { description: Attachment bytes (application/octet-stream) } /v1/inbox-messages/{id}/reply: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Inboxes] summary: Reply (or reply-all) to a message, staying in the same thread requestBody: content: application/json: schema: { $ref: "#/components/schemas/InboxCompose" } responses: "202": { description: Reply accepted } /v1/inbox-messages/{id}/forward: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Inboxes] summary: Forward a message to new recipients requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/InboxCompose" } responses: "202": { description: Forward accepted } /v1/inbox-messages/{id}/read: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Inboxes] summary: Mark a message read or unread requestBody: content: application/json: schema: type: object properties: { read: { type: boolean } } responses: "200": { description: Updated } /v1/inbox-messages/{id}/labels: parameters: [{ $ref: "#/components/parameters/Id" }] put: tags: [Inboxes] summary: Replace a message's labels requestBody: content: application/json: schema: type: object properties: { labels: { type: array, items: { type: string } } } responses: "200": { description: Updated message } /v1/threads/{id}/labels: parameters: [{ $ref: "#/components/parameters/Id" }] put: tags: [Inboxes] summary: Replace a thread's labels requestBody: content: application/json: schema: type: object properties: { labels: { type: array, items: { type: string } } } responses: "200": { description: Updated thread } /v1/inboxes/{id}/search: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Full-text search an inbox's messages (subject, body, attachment text) parameters: - { in: query, name: q, required: true, schema: { type: string } } - { in: query, name: folder, schema: { type: string }, description: "Restrict to a folder, or 'all' (default excludes trash)" } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: Matching messages } /v1/inboxes/{id}/events: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: Server-Sent Events stream of an inbox's events description: > A `text/event-stream` of events (message.received, message.sent, approval.required, ...). Resume with the standard `Last-Event-ID` header or the `after_id` query parameter. parameters: - { in: query, name: after_id, schema: { type: integer } } responses: "200": { description: SSE stream, content: { text/event-stream: {} } } /v1/inboxes/{id}/ws: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Inboxes] summary: WebSocket stream of an inbox's events description: > A WebSocket (RFC 6455) carrying the same events as the SSE stream, one JSON object per text frame. Authenticate with the `Authorization: Bearer` header, or — for browsers, which cannot set that header — by offering the `cyfox.` subprotocol. Tokens are never read from the query string. Resume with the `after_id` query parameter. Capped at 5 concurrent streams per key (HTTP 429 on exceed). The server pings every 25s. parameters: - { in: query, name: after_id, schema: { type: integer } } responses: "101": { description: Switching Protocols (WebSocket) } "401": { description: Missing or invalid credentials } "429": { description: Too many concurrent streams for this key } /mcp: post: tags: [MCP] summary: Hosted Model Context Protocol endpoint (JSON-RPC 2.0) description: > A single JSON-RPC endpoint exposing the agent's inbox as MCP tools (list_inboxes, list_messages, search_messages, get_message, send_message, reply_message). Authenticate with an API key; inbox-scoped keys confine all tools to their inbox. Supports `initialize`, `tools/list`, `tools/call`. requestBody: required: true content: application/json: schema: type: object properties: jsonrpc: { type: string, example: "2.0" } id: {} method: { type: string, example: tools/call } params: { type: object } responses: "200": { description: JSON-RPC response } /v1/inboxes/{id}/approvals: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Shield] summary: List outbound messages held for approval (Agent Shield) description: > Messages are held when the inbox policy triggers: an outbound DLP hit (dlp=enforce), a reply in a tainted thread (require_approval_on_taint), or exceeding max_send_per_hour. parameters: - { in: query, name: status, schema: { type: string, enum: [pending, approved, rejected, sent] } } - { in: query, name: limit, schema: { type: integer } } responses: "200": { description: Approvals } /v1/approvals/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Shield] summary: Get an approval responses: "200": { description: Approval } /v1/approvals/{id}/approve: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Shield] summary: Approve a held message and submit it for delivery responses: "200": { description: Approved and queued } "409": { description: Already decided } /v1/approvals/{id}/reject: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Shield] summary: Reject a held message (it is never delivered) responses: "200": { description: Rejected } /v1/tenants/{id}/smtp-credentials: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Tenants] summary: Create an SMTP credential for the tenant description: The password is returned once. requestBody: content: application/json: schema: type: object properties: username: { type: string } allowed_cidrs: { type: array, items: { type: string, example: 203.0.113.0/24 } } responses: "201": { description: Credential and one-time password } get: tags: [Tenants] summary: List the tenant's SMTP credentials responses: "200": { description: Credentials } /v1/smtp-credentials/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Tenants] summary: Revoke an SMTP credential responses: "204": { description: Revoked } /v1/smtp-credentials/{id}/rotate: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Tenants] summary: Rotate an SMTP credential's password description: Issues a new password in place, shown once. A tenant_admin may rotate its own tenant's credentials. responses: "200": { description: Credential and one-time new password } /v1/api-keys: post: tags: [Tenants] summary: Create an API key description: With `tenant_id` the key is confined to that tenant and may only hold `send`, `read`, `feedback`. The key is returned once. requestBody: required: true content: application/json: schema: type: object required: [name, scopes] properties: name: { type: string } tenant_id: { type: string, format: uuid } inbox_id: { type: string, format: uuid, description: Confine the key to a single inbox (inherits its tenant) } scopes: { type: array, items: { type: string, enum: [send, read, feedback, suspend, admin] } } allowed_cidrs: { type: array, items: { type: string, example: 203.0.113.0/24 }, description: Optional source-IP allow-list } expires_in_days: { type: integer, description: Optional expiry; omitted or 0 means no expiry } responses: "201": { description: Key (shown once) } get: tags: [Tenants] summary: List API keys responses: "200": { description: Keys without secrets } /v1/api-keys/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Tenants] summary: Revoke an API key responses: "204": { description: Revoked } /v1/api-keys/{id}/rotate: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Tenants] summary: Rotate an API key's secret description: Issues a new secret in place, keeping the key id, scopes, expiry and allow-list. The old secret stops working immediately. A tenant_admin may rotate its own tenant's keys. responses: "200": { description: Key (new secret shown once) } /v1/account: get: tags: [Account] summary: Get the caller's account settings description: Account principals see all settings; tenant principals see only id, name, status and data_region. responses: "200": { description: Account settings } patch: tags: [Account] summary: Update account settings (account admins only) requestBody: content: application/json: schema: type: object properties: suspend_mode: { type: string, enum: [hold, reject] } hold_ttl_seconds: { type: integer } body_retention_days: { type: integer } metadata_retention_days: { type: integer } hide_suppression_reason: { type: boolean } require_outbound_tls: { type: boolean } legal_hold: { type: boolean } responses: "200": { description: Updated account settings } /v1/overview: get: tags: [Account] summary: Aggregated dashboard payload description: 24h totals, per-tenant health, and (for account principals) queue depth, unacknowledged alert count and active pauses. Tenant principals get only their own tenant's slice. responses: "200": { description: Overview } /v1/inboxes/{id}/connect/{provider}: post: tags: [Inboxes] summary: Start the OAuth flow to back an inbox with Gmail or Microsoft 365 (account admins) description: Returns a provider consent URL. Redirect the admin's browser there; the provider returns to /v1/inboxes/connect/callback, which stores the (encrypted) tokens and flips the inbox to the provider. parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } - { in: path, name: provider, required: true, schema: { type: string, enum: [google, microsoft] } } responses: "200": description: Consent URL content: application/json: schema: type: object properties: auth_url: { type: string, format: uri } "400": { description: Unsupported provider } "503": { description: Connected mailboxes are not configured on this server } /v1/inboxes/{id}/connect: delete: tags: [Inboxes] summary: Disconnect a provider-backed inbox (account admins) description: Revokes the stored tokens at the provider (best effort), removes the linkage and reverts the inbox to the native store. parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } responses: "200": { description: Disconnected } "503": { description: Connected mailboxes are not configured on this server } /v1/inboxes/connect/callback: get: tags: [Inboxes] summary: OAuth redirect target (public; validated by signed state) description: The provider redirects the admin's browser here after consent. Not called directly by API clients; it validates the signed state, exchanges the code and redirects back to the web app. security: [] parameters: - { in: query, name: code, schema: { type: string } } - { in: query, name: state, schema: { type: string } } responses: "302": { description: Redirect back to the web app } "400": { description: Invalid or expired state } /v1/billing/plans: get: tags: [Billing] summary: List the active plan catalog description: Each plan may carry a stripe_price_id; plans with one can be purchased via /v1/billing/checkout. responses: "200": { description: Plans } /v1/billing/checkout: post: tags: [Billing] summary: Start a Stripe Checkout session to subscribe to a plan (account admins) description: Returns a hosted Stripe Checkout URL. Requires the target plan to carry a stripe_price_id and the server to be configured with Stripe keys. requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: { type: string, format: uuid } responses: "200": description: Checkout URL content: application/json: schema: type: object properties: url: { type: string, format: uri } "409": { description: Plan has no Stripe price configured } "503": { description: Stripe is not configured on this server } /v1/billing/portal: post: tags: [Billing] summary: Open the Stripe billing portal for the account (account admins) description: Returns a Stripe billing portal URL where the customer can manage payment methods and subscriptions. Requires an existing Stripe customer. responses: "200": description: Portal URL content: application/json: schema: type: object properties: url: { type: string, format: uri } "409": { description: No Stripe customer for this account } "503": { description: Stripe is not configured on this server } /webhooks/stripe: post: tags: [Billing] summary: Stripe webhook endpoint (public; verified by signature) description: Receives Stripe events. The raw body is verified against the Stripe-Signature header using the configured webhook secret. Handles checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted to keep plan assignments and account tier in sync. security: [] responses: "200": { description: Event accepted } "400": { description: Invalid signature } /v1/billing/assignments: get: tags: [Billing] summary: List this account's plan assignments (account plan and tenant rate cards) responses: "200": { description: Assignments } post: tags: [Billing] summary: Assign a plan as a tenant's rate card (account admins) requestBody: required: true content: application/json: schema: type: object required: [plan_id, tenant_id] properties: plan_id: { type: string, format: uuid } tenant_id: { type: string, format: uuid } effective_from: { type: string, format: date } responses: "201": { description: Assignment } /v1/billing/usage: get: tags: [Billing] summary: Metered usage for a month parameters: - { in: query, name: month, schema: { type: string, example: "2026-10" } } - { in: query, name: tenant_id, schema: { type: string, format: uuid } } responses: "200": { description: Per-day and per-tenant usage } /v1/billing/preview: get: tags: [Billing] summary: Preview charges for a month (base + overage, minor units) description: Labelled a preview; no month is locked or closed. Account principals also see the account-level charge. parameters: - { in: query, name: month, schema: { type: string, example: "2026-10" } } responses: "200": { description: Preview charges } /v1/billing/export: get: tags: [Billing] summary: Export usage as CSV or JSON parameters: - { in: query, name: month, schema: { type: string, example: "2026-10" } } - { in: query, name: format, schema: { type: string, enum: [csv, json], default: json } } responses: "200": { description: Usage file } /v1/suspensions: post: tags: [Control] summary: Pause by API key, SMTP credential, domain, sender, tenant, tag, recipient domain or whole account description: | `queue_action` decides what happens to mail **already queued** and to new submissions while paused: * `hold` (default) - queued mail is parked, new mail is accepted but held. Nothing is lost or sent. * `reject` - queued mail is parked, new submissions are refused with `403` / SMTP `4.7.1`. Takes effect for new submissions and for already-queued mail within moments (and on every instance via Postgres LISTEN/NOTIFY). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SuspendRequest" } responses: "201": { description: Created } "200": { description: Already paused for that scope } "404": { description: scope_value does not exist in this account } get: tags: [Control] summary: List pauses parameters: - { in: query, name: status, schema: { type: string, enum: [active, resumed] } } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Suspensions with `held_count` } /v1/suspensions/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Control] summary: One pause, including the evidence that triggered it responses: "200": { description: Suspension } /v1/suspensions/{id}/resume: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Control] summary: Manual release after review description: "`release` re-queues held mail; `purge` cancels it. The note and the actor are written to the audit log. The guardian will not re-suspend the same scope for the resume grace period (15 min) so the old spike does not undo the release." requestBody: content: application/json: schema: { $ref: "#/components/schemas/ResumeRequest" } responses: "200": { description: Resumed, with `affected_deliveries` } /v1/suppressions: get: tags: [Feedback] summary: Suppression list parameters: - { in: query, name: tenant_id, schema: { type: string, format: uuid } } - { in: query, name: reason, schema: { type: string, enum: [hard_bounce, complaint, unsubscribe, manual] } } - { in: query, name: q, schema: { type: string } } - { $ref: "#/components/parameters/Limit" } - { in: query, name: offset, schema: { type: integer } } responses: "200": { description: Entries and total } post: tags: [Feedback] summary: Add an address manually requestBody: required: true content: application/json: schema: type: object required: [address] properties: address: { type: string } tenant_id: { type: string, format: uuid } reason: { type: string, enum: [manual, unsubscribe, complaint, hard_bounce] } detail: { type: string } responses: "201": { description: Added } /v1/suppressions/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Feedback] summary: Remove an address from the list responses: "204": { description: Removed } /v1/complaints: post: tags: [Feedback] summary: Report a spam complaint (feedback loop) description: Marks the delivery complained, suppresses the recipient for **all** tenants of the account and feeds the complaint-rate detection. requestBody: required: true content: application/json: schema: type: object required: [message_id, recipient] properties: message_id: { type: string, format: uuid } recipient: { type: string } detail: { type: string } responses: "200": { description: Recorded } /v1/webhooks: post: tags: [Webhooks] summary: Register a webhook description: The signing secret is returned once. Private/loopback targets are refused. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, format: uri } tenant_id: { type: string, format: uuid, description: Restrict to one tenant } event_types: { type: array, items: { type: string }, description: Empty means all events } responses: "201": { description: Webhook and one-time secret } get: tags: [Webhooks] summary: List webhooks responses: "200": { description: Webhooks } /v1/webhooks/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] patch: tags: [Webhooks] summary: Enable or disable requestBody: content: application/json: schema: type: object properties: { status: { type: string, enum: [active, disabled] } } responses: "204": { description: Updated } delete: tags: [Webhooks] summary: Delete responses: "204": { description: Deleted } /v1/webhooks/{id}/attempts: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Webhooks] summary: Delivery attempts and failures parameters: - { in: query, name: status, schema: { type: string, enum: [pending, failed, delivered, dead] } } responses: "200": { description: Attempts } /v1/webhooks/{id}/replay: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Webhooks] summary: Re-send failed or dead attempts parameters: [{ in: query, name: since, schema: { type: string, format: date-time } }] responses: "200": { description: Number of attempts re-queued } /v1/rules: get: tags: [Detection] summary: Effective detection rules (platform defaults and overrides) responses: "200": { description: Rules } put: tags: [Detection] summary: Create or replace an override description: | A rule with the same name as a platform default replaces it for this account (or tenant, with `tenant_id`). `enabled: false` switches the default off. `baseline_multiplier` makes the rule relative to the tenant's own 7-day average (anomaly detection); otherwise `threshold` is absolute. `min_volume` prevents tiny samples from firing. Actions: `alert`, `throttle` (lowers the tenant's per-minute rate), `suspend` (pauses only the offending scope). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Rule" } responses: "200": { description: Saved rule } /v1/rules/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Detection] summary: Delete an override (restores the default) responses: "204": { description: Deleted } /v1/alerts: get: tags: [Detection] summary: Alerts raised by the guardian parameters: [{ in: query, name: unacked, schema: { type: string, enum: ["1"] } }] responses: "200": { description: Alerts } /v1/alerts/{id}/ack: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Detection] summary: Acknowledge responses: "204": { description: Acknowledged } /v1/alert-contacts: get: tags: [Detection] summary: Who gets notified responses: "200": { description: Contacts } post: tags: [Detection] summary: Add a notification contact requestBody: required: true content: application/json: schema: type: object required: [channel, target] properties: channel: { type: string, enum: [email, webhook, slack, pagerduty] } target: { type: string, description: "Address, URL or PagerDuty routing key" } min_severity: { type: string, enum: [info, warning, critical], default: warning } responses: "201": { description: Created } /v1/alert-contacts/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] delete: tags: [Detection] summary: Remove a contact responses: "204": { description: Deleted } /v1/metrics: get: tags: [Observability] summary: Time series for a scope parameters: - { in: query, name: scope_type, schema: { type: string, enum: [tenant, domain, sender, credential, api_key] } } - { in: query, name: scope_value, schema: { type: string } } - { in: query, name: from, schema: { type: string, format: date-time } } - { in: query, name: to, schema: { type: string, format: date-time } } - { in: query, name: step, schema: { type: integer, default: 300, description: seconds } } responses: "200": { description: Counters per interval (recipients, delivered, hard_bounces, soft_bounces, blocks, deferrals, complaints, auth_failures) } /v1/reputation: get: tags: [Observability] summary: 24h reputation per tenant and per sending domain description: Bounce, block and complaint rates with a `health` of `ok`, `warning` or `critical` using the default guardian thresholds. responses: "200": { description: Rows per tenant and domain } /v1/queue: get: tags: [Observability] summary: Queue depth per delivery status responses: "200": { description: Counts } /v1/audit: get: tags: [Observability] summary: Audit log description: Append-only and hash-chained; every mutating API call, every automatic stop and every resume is recorded with actor and IP. parameters: - { in: query, name: action, schema: { type: string, example: suspension.resume } } - { in: query, name: before_id, schema: { type: integer, format: int64 } } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Entries, newest first } /admin/v1/accounts: post: tags: [Admin] summary: Create an account and its bootstrap admin key security: [{ adminToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } data_region: { type: string, default: eu } sandbox: { type: boolean, description: "Sandbox accounts may send from unverified domains" } responses: "201": { description: Account and one-time key } get: tags: [Admin] summary: List accounts security: [{ adminToken: [] }] responses: "200": { description: Accounts } /admin/v1/accounts/{id}/pause: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Admin] summary: CYFOX operator pauses a whole account security: [{ adminToken: [] }] responses: "200": { description: Suspension } /admin/v1/accounts/{id}/resume: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Admin] summary: CYFOX operator resumes an account security: [{ adminToken: [] }] responses: "200": { description: Suspension and affected deliveries } /admin/v1/accounts/{id}/health: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Admin] summary: CYFOX operator account health overview description: The dashboard overview (24h totals, per-tenant health, queue, alerts, active pauses) plus the account record, for any account. security: [{ adminToken: [] }] responses: "200": { description: Account health overview } /admin/v1/accounts/{id}/plan: parameters: [{ $ref: "#/components/parameters/Id" }] post: tags: [Admin] summary: Assign the account-level plan CYFOX bills the SaaS security: [{ adminToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: { type: string, format: uuid } effective_from: { type: string, format: date } responses: "201": { description: Assignment } /admin/v1/plans: get: tags: [Admin] summary: List the plan catalog security: [{ adminToken: [] }] parameters: - { in: query, name: active, schema: { type: string, enum: ["1"] } } responses: "200": { description: Plans } post: tags: [Admin] summary: Create a plan security: [{ adminToken: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PlanInput" } responses: "201": { description: Plan } /admin/v1/plans/{id}: parameters: [{ $ref: "#/components/parameters/Id" }] get: tags: [Admin] summary: Get a plan security: [{ adminToken: [] }] responses: "200": { description: Plan } patch: tags: [Admin] summary: Update a plan security: [{ adminToken: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PlanInput" } responses: "200": { description: Plan } delete: tags: [Admin] summary: Archive a plan security: [{ adminToken: [] }] responses: "204": { description: Archived } /admin/v1/billing/accounts: get: tags: [Admin] summary: Cross-account billing overview for a month security: [{ adminToken: [] }] parameters: - { in: query, name: month, schema: { type: string, example: "2026-10" } } responses: "200": { description: Per-account usage and preview charge } /admin/v1/audit/verify: get: tags: [Admin] summary: Verify the audit hash chain security: [{ adminToken: [] }] responses: "200": { description: "`intact`, and `broken_at_id` when tampering is detected" } /healthz: get: { tags: [Observability], summary: Liveness, security: [], responses: { "200": { description: ok } } } /readyz: get: { tags: [Observability], summary: Readiness (database and pause state loaded), security: [], responses: { "200": { description: ready }, "503": { description: not ready } } } components: securitySchemes: bearer: { type: http, scheme: bearer } adminToken: { type: apiKey, in: header, name: X-Admin-Token } parameters: Id: { in: path, name: id, required: true, schema: { type: string, format: uuid } } Limit: { in: query, name: limit, schema: { type: integer, default: 100 } } responses: Invalid: description: Request rejected content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } Forbidden: description: Not allowed, or sending is paused with queue_action=reject (`sending_suspended`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } NotFound: description: Not found (also returned for resources of other tenants) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } Throttled: description: "`rate_limited`, `quota_exceeded` or `unavailable`; retry after the `Retry-After` seconds" headers: { Retry-After: { schema: { type: integer } } } content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } schemas: PlanInput: type: object required: [name] description: All monetary amounts are integer minor units (e.g. cents). properties: name: { type: string } currency: { type: string, example: USD } base_price_minor: { type: integer, format: int64 } included_recipients: { type: integer, format: int64 } overage_per_thousand_minor: { type: integer, format: int64 } tiers: type: array items: type: object properties: up_to_recipients: { type: integer, format: int64, description: Cumulative recipients where this band ends; 0 means open-ended } price_per_thousand_minor: { type: integer, format: int64 } Error: type: object properties: error: type: object properties: { code: { type: string }, message: { type: string } } SendRequest: type: object required: [from, to] properties: tenant_id: { type: string, format: uuid, description: Required for account-wide keys } from: { type: string, example: "Shop ", description: Domain must be registered (and verified) for the tenant } to: { type: array, items: { type: string } } cc: { type: array, items: { type: string } } bcc: { type: array, items: { type: string } } reply_to: { type: string } subject: { type: string } text: { type: string } html: { type: string } headers: { type: object, additionalProperties: { type: string } } tags: { type: array, items: { type: string } } metadata: { type: object, additionalProperties: { type: string } } idempotency_key: { type: string } attachments: type: array description: Up to 25 MB total (decoded) across all attachments. items: { $ref: "#/components/schemas/Attachment" } Attachment: type: object required: [content] properties: filename: { type: string, example: invoice.pdf } content_type: { type: string, example: application/pdf, default: application/octet-stream } content: { type: string, format: byte, description: Base64-encoded bytes } content_id: { type: string, description: For inline (cid:) references in HTML } inline: { type: boolean, default: false } InboxPolicy: type: object description: Per-inbox Agent Shield policy (stored on the inbox). properties: shield_mode: { type: string, enum: [off, observe, enforce], description: Inbound inspection mode } block_unauthenticated: { type: boolean, description: Block inbound mail failing DMARC } allow_senders: { type: array, items: { type: string }, description: Addresses/domains always allowed } block_senders: { type: array, items: { type: string }, description: Addresses/domains always blocked } dlp: { type: string, enum: [off, observe, enforce], description: Outbound DLP mode } max_send_per_hour: { type: integer, description: Outbound send cap; excess is held for approval } allow_recipients: { type: array, items: { type: string }, description: Outbound recipient domain allowlist } require_approval_on_taint: { type: boolean, description: Hold replies in tainted threads for approval } InboxCompose: type: object description: Compose/reply/forward body. From is always the inbox address. properties: to: { type: array, items: { type: string } } cc: { type: array, items: { type: string } } bcc: { type: array, items: { type: string } } subject: { type: string } text: { type: string } html: { type: string } reply_all: { type: boolean, description: "On reply: also copy the original To/Cc" } send_at: { type: string, format: date-time, description: Future time to create a scheduled draft } attachments: { type: array, items: { $ref: "#/components/schemas/Attachment" } } DraftCompose: type: object description: Draft body. From is always the inbox address. Set send_at to schedule delivery. properties: to: { type: array, items: { type: string } } cc: { type: array, items: { type: string } } bcc: { type: array, items: { type: string } } subject: { type: string } text: { type: string } html: { type: string } attachments: { type: array, items: { $ref: "#/components/schemas/Attachment" } } reply_to_id: { type: string, format: uuid, description: Message this draft replies to (threads it) } send_at: { type: string, format: date-time, description: Future time to auto-send; omit for a plain draft } SendResponse: type: object properties: message_id: { type: string, format: uuid } queued: { type: integer } held: { type: integer, description: Recipients parked because a pause applies } suppressed: { type: integer } duplicate: { type: boolean } hold_scope: { type: string, example: "tenant:3f2c..." } TenantCreate: type: object required: [external_id, name] properties: external_id: { type: string, description: Your own id for the customer } name: { type: string } quota_daily: { type: integer, minimum: 0, description: Recipients per UTC day } quota_monthly: { type: integer, minimum: 0 } rate_per_minute: { type: integer, minimum: 0, description: Recipients per minute } rate_per_hour: { type: integer, minimum: 0 } max_recipients_per_message: { type: integer, minimum: 1 } Tenant: allOf: - $ref: "#/components/schemas/TenantCreate" - type: object properties: id: { type: string, format: uuid } status: { type: string, enum: [active, disabled] } created_at: { type: string, format: date-time } PauseRequest: type: object properties: reason: { type: string } queue_action: { type: string, enum: [hold, reject], default: hold } SuspendRequest: type: object required: [scope_type, scope_value] properties: scope_type: { type: string, enum: [account, tenant, domain, sender, credential, api_key, rule] } scope_value: type: string description: "Tenant/credential/API key id, domain name, sender address, `tag:` or `recipient_domain:`" reason: { type: string } queue_action: { type: string, enum: [hold, reject] } ResumeRequest: type: object properties: action: { type: string, enum: [release, purge], default: release } note: { type: string, description: Why the release is safe; recorded in the audit log } Rule: type: object required: [name, metric, scope_type, window_seconds, action] properties: tenant_id: { type: string, format: uuid } name: { type: string } metric: type: string enum: [recipients, hard_bounce_rate, soft_bounce_rate, bounce_rate, block_rate, complaint_rate, deferral_rate, auth_failures, distinct_ips, hard_bounces, complaints, blocks, deferrals] scope_type: { type: string, enum: [tenant, domain, sender, credential, api_key] } window_seconds: { type: integer, minimum: 60, maximum: 604800 } threshold: { type: number } min_volume: { type: integer } baseline_multiplier: { type: number, nullable: true, description: "Fire when value exceeds baseline x multiplier (7-day average of the same window)" } action: { type: string, enum: [alert, throttle, suspend] } severity: { type: string, enum: [info, warning, critical] } enabled: { type: boolean }