openapi: 3.1.0 info: title: Blindrelay Machine API version: 1.1.0 description: | Transactional email relay for security-conscious teams. Message content is not retained on Blindrelay infrastructure. Delivery signals are exported via OTLP and optional webhooks. ## Machine authentication All `/v1/*` routes use API key Bearer auth (`Authorization: Bearer `). Create scoped keys in the Blindrelay UI (Keys). Store the one-time secret as `BLINDRELAY_API_KEY` for CLI or Agent Plugin PAT, or `blindrelay auth login` which stores a scoped key locally (passkey reveal is browser-only). Hosted Streamable HTTP MCP is infrastructure behind the **Agent Plugin**. OAuth Connect: `POST https://api.blindrelay.app/mcp` (staging: `api.stag`). PAT (`br_…` keys, same as CLI): `POST https://api-pat.blindrelay.app/mcp` only (staging: `api-pat.stag`). Do not send a PAT to the OAuth host. See [docs/mcp.md](mcp.md). The MCP protocol is not REST; tool calls map to these `/v1/*` routes with the same scopes. ### Scope matrix | Scope | Routes | |-------|--------| | `send` | `POST /v1/send` | | `read` | `GET /v1/domains`, `GET /v1/domains/{id}`, `GET /v1/domains/{id}/dns-guide`, `GET /v1/domains/{id}/dmarc`, `GET /v1/domains/{id}/dmarc/sources`, `GET /v1/usage`, `GET /v1/usage/history`, `GET /v1/spend-cap`, `GET /v1/delivery/stats`, `GET /v1/suppression`, `GET /v1/suppression/export`, `POST /v1/suppression/lookup`, `GET /v1/audit`, `GET /v1/otlp`, `GET /v1/delivery-settings`, `GET /v1/dedicated-ip`, `GET /v1/inbound-forward`, `GET /v1/inbound-forward/endpoints` | | `write` | `POST /v1/domains`, `POST .../verify`, `POST .../rotate-dkim`, `PUT .../dedicated-ip`, `DELETE .../{id}`, `POST /v1/suppression`, `DELETE /v1/suppression/{id}`, `PUT /v1/spend-cap`, `PUT /v1/otlp`, `POST /v1/otlp/test`, `PUT /v1/delivery-settings`, `POST /v1/delivery-settings/test`, `POST /v1/delivery-settings/rotate-signing-secret`, `POST /v1/dedicated-ip/request`, `POST /v1/dedicated-ip/{id}/cancel`, `POST /v1/dedicated-ip/{id}/resume-checkout`, `POST /v1/dedicated-ip/{id}/set-default`, `POST /v1/dedicated-ip/dismiss-closure`, `POST /v1/inbound-forward/aliases`, `PATCH`/`DELETE /v1/inbound-forward/aliases/{id}`, `POST /v1/inbound-forward/endpoints`, `PATCH`/`DELETE /v1/inbound-forward/endpoints/{id}`, `POST .../endpoints/{id}/rotate-secret`, `POST .../endpoints/{id}/test` | | any | `GET /v1/setup` (so a key can always learn which scopes it lacks) | Missing scope → `403` with code `scope_missing`. ### One failure, one answer Every error uses the `{"error": {"code", "message"}}` envelope (`ErrorResponse`), on `/v1/*`, the session `/api/*` routes, CLI output (`code: message`) and MCP tool errors (the same envelope plus `status`, returned as a tool error). An unknown or foreign domain id answers `404 domain_not_found` everywhere (domain routes, `domain_id` on OTLP and delivery-hook settings, dedicated IP pins, inbound forward). A body, path id or query the server cannot parse answers `400 invalid_payload`, the same as a field that fails validation; the message names the kind of problem and at most a schema field, never a request value. ### Request throttles vs product budgets Every request throttle (per-IP sign-in limit, magic-link / OTP cooldown, per-user write cap, migration-discount and web-vitals form limits) answers `429` with code `rate_limited`, `error.retry_after_secs` and a `Retry-After` header (see `RateLimited`). Product budgets keep their own codes: `quota_exceeded` (monthly quota), `spending_cap_reached` (the cap a workspace set below the 150% hard stop; see `PUT /v1/spend-cap`), `rate_limit_exceeded` (per-domain sends per minute, unique recipients per hour) and `warmup_limit_exceeded`. So the two `429` codes are not interchangeable: `rate_limit_exceeded` on `POST /v1/send` is the abuse limit and clears when its window resets, while `rate_limited` is a request throttle whose `Retry-After` says exactly when to try again. ### Web UI write safety (session routes, not `/v1`) Session-authenticated (`/api/*`, `/ops/*`, `/auth/*` with a session) non-GET requests may carry `Idempotency-Key` (see `WriteIdempotencyKey`): the same key with the same method, path and body replays the stored response with `Idempotent-Replayed: true`; a key still in flight answers `409 idempotency_in_progress`; the same key with a different request `422 idempotency_key_reused`; a malformed key `400 invalid_idempotency_key`. 5xx, 408 and 429 are not stored; keys expire after 24 hours; requests without the header behave as before. Routes that carry a recipient address or a credential (`POST /api/suppression`, `POST /api/suppression/lookup`, `POST /ops/send-test`, `POST /api/keys`, `POST /api/keys/{id}/rotate`, `POST /api/keys/{id}/reveal/finish`, `POST /oauth/complete`) are never keyed (no content on disk) and are idempotent by themselves. Two concurrent money transitions (plan Checkout, dedicated IP request / cancel / resume-checkout) answer `409 action_in_progress` to the second request. `/v1/*` routes are not keyed; only `POST /v1/send` honours `Idempotency-Key`, with the envelope semantics described there. Production base URL: `https://api.blindrelay.app` servers: - url: https://api.blindrelay.app description: Production - url: https://api.stag.blindrelay.app description: Staging paths: /v1/setup: get: operationId: getSetupStatus summary: Where workspace setup stands (guided setup) security: - bearerAuth: [] description: | Any scope. The ordered setup steps `connected`, `domain_registered`, `dns_published`, `domain_verified` and `test_send`, the same computation as the dashboard checklist (`GET /api/setup`), the CLI `blindrelay setup` command and the MCP `setup_status` tool. Each step has `done`, an optional `blocked_reason` / `blocked_code` (`previous_step_pending`, `scope_missing`, `insufficient_role`, or a plan restriction code), the calling key's `missing_scopes` for it and a `next_action` (dashboard path and link, CLI commands, MCP tools, required scopes). `dns_published` lists the required records the last DNS check did not find (`pending_records`), with the check's issue code and message. `plan_restrictions` names plan limits that block a step: `plan_restricted` (Free keys carry only `send`, so the domain steps happen in the dashboard), `domain_limit_reached` and `quota_exceeded`. Derived from existing data; nothing is stored and no message content or recipient is read. The endpoint never sends: the test email is an ordinary `POST /v1/send` the caller makes after the user confirms it. responses: '200': description: Setup status content: application/json: schema: $ref: '#/components/schemas/SetupStatus' '401': $ref: '#/components/responses/Unauthorized' /v1/send: post: operationId: sendEmail summary: Queue a transactional email for delivery description: | Accepts a message, enqueues one envelope per recipient, and returns `202 Accepted` immediately. Each recipient gets one delivery attempt from the application, paced per egress IP. If the recipient's server cannot take it immediately the message waits on disk and is retried for up to 24 hours, then deleted, unless the workspace turned retries off (`delivery_retry_enabled`), in which case a temporary failure is final. Use `Idempotency-Key` for safe retries without duplicate sends (24-hour TTL). `to` may be a string or an array of up to 50 addresses (transactional fan-out, not campaigns). `cc` and `bcc` are optional and accept a string or array; `to`, `cc` and `bcc` share the 50 address limit, deduplicated case-insensitively with precedence `to` over `cc` over `bcc`. Every address is its own envelope, passes suppression and abuse checks and burns its own quota. When `cc` is present every copy shows the `To` and `Cc` lists; without `cc` a Bcc copy shows `To: undisclosed-recipients:;`; `bcc` addresses never appear in any header of any copy. `attachments` (paid plans) adds up to 10 files, 10,240,000 decoded bytes in total. With attachments each recipient costs `ceil(message bytes / 1 MiB)` quota units, the SMTP rule; without attachments each recipient costs 1. SMTP submission remains one `RCPT TO` per transaction. `tags` adds up to 5 opaque labels, returned on the message's webhook events. An invalid tag answers `400 invalid_tag`, naming the position (`tags[1].value`) and never the value. An API key created with `sandbox: true` is accepted and reported as usual and delivers nothing. It uses no quota, skips suppression and the unique-recipient limit, keeps domain verification, and has its own per-domain rate limit. `hard_bounce@sandbox.` and `soft_bounce@sandbox.` choose those outcomes; every other address is reported `delivered` without being contacted. security: - bearerAuth: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SendEmailRequest' examples: singleRecipient: summary: Single recipient value: from: noreply@example.com to: user@example.com subject: Reset your password html:

Click here to reset.

text: Click here to reset responses: '202': description: Accepted — message(s) queued for delivery content: application/json: schema: $ref: '#/components/schemas/SendEmailResponse' examples: queued: summary: Accepted value: id: 550e8400-e29b-41d4-a716-446655440000 ids: - 550e8400-e29b-41d4-a716-446655440000 status: queued withWarnings: summary: Accepted with a body warning value: id: 550e8400-e29b-41d4-a716-446655440000 ids: - 550e8400-e29b-41d4-a716-446655440000 status: queued warnings: [missing_text_alternative] '400': $ref: '#/components/responses/InvalidPayload' '401': $ref: '#/components/responses/Unauthorized' '413': description: Request body over the send limit (about 14 MiB of JSON, enough for the maximum base64 attachments; html and text are each capped at 1 MiB) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: payload_too_large message: Request body is too large '403': description: Forbidden — domain, scope, suppression, or tenant status content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' examples: domainNotVerified: value: error: code: domain_not_verified message: sending domain not verified recipientSuppressed: value: error: code: recipient_suppressed message: recipient is suppressed scopeMissing: value: error: code: scope_missing message: api key missing 'send' scope tenantSuspended: value: error: code: tenant_suspended message: tenant is suspended '404': description: Tenant not found content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: unknown_tenant message: tenant not found '429': description: 'Monthly quota exceeded (`quota_exceeded`, hard stop at 150% of plan quota), the workspace''s own monthly cap reached (`spending_cap_reached`) the send abuse limit (`rate_limit_exceeded`, per-domain send rate or unique recipients per hour) or a dedicated IP at its daily warmup limit whose extra mail cannot use the shared pool (`warmup_limit_exceeded`: workspace over the normal abuse limits, the shared IP at its own limit, or no shared route; carries `Retry-After` until midnight UTC)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: quota_exceeded message: monthly quota exceeded '503': description: Temporary unavailability — retry with backoff content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' examples: draining: value: error: code: draining message: server is draining for deploy; retry shortly queueAtCapacity: value: error: code: queue_at_capacity message: delivery queue is at capacity; try again shortly '500': $ref: '#/components/responses/InternalError' /v1/domains: get: operationId: listDomains summary: List sending domains security: - bearerAuth: [] description: Requires `read`. responses: '200': description: Domain list '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' post: operationId: registerDomain summary: Register a sending domain security: - bearerAuth: [] description: | Requires `write`. Refused with 403 `domain_limit_reached` once the workspace holds as many sending domains as its plan allows (Free 1; paid plans unlimited under a fair-use ceiling), whether its domains use the shared pool or a dedicated IP. requestBody: required: true content: application/json: schema: type: object required: [domain] properties: domain: type: string selector: type: string description: Optional custom DKIM selector dedicated_ip_assignment_id: type: string format: uuid description: Optional live dedicated IP assignment of this workspace to pin; omit to follow the workspace default responses: '201': description: Domain created with DKIM DNS details '400': description: '`invalid_payload`: an invalid domain or selector, or the domain is already registered' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `domain_limit_reached` (the plan''s domain ceiling)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: '`dedicated_ip_not_found`: `dedicated_ip_assignment_id` is unknown or belongs to another workspace' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`invalid_status_transition`: the pinned dedicated IP assignment is not live yet (for example `requested` or `pending_payment`)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/domains/{id}: get: operationId: getDomain summary: Get sending domain detail (DNS / DKIM) security: - bearerAuth: [] description: Requires `read`. parameters: - $ref: '#/components/parameters/DomainId' responses: '200': description: Domain detail '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' delete: operationId: deleteDomain summary: Delete a sending domain security: - bearerAuth: [] description: Requires `write`. Refused while messages from the domain are still queued. parameters: - $ref: '#/components/parameters/DomainId' responses: '204': description: Deleted '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' '409': description: '`has_pending_envelopes`: messages from this domain are still queued; retry once they are delivered' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/domains/{id}/dmarc: get: operationId: getDomainDmarc summary: DMARC aggregate report summary and p=reject readiness security: - bearerAuth: [] description: | Requires `read` and the Growth plan or higher; below it the answer is 403 `dmarc_plan_required` (every plan gets the weekly digest by email, and `dmarc_reporting` on the domain). Aggregate reports only, processed in the EU. Readiness is evidence from the stored reports over `readiness.window_days`, not a guarantee. parameters: - $ref: '#/components/parameters/DomainId' responses: '200': description: Summary content: application/json: schema: $ref: '#/components/schemas/DmarcSummary' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `dmarc_plan_required` on Free' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': $ref: '#/components/responses/DomainNotFound' /v1/domains/{id}/dmarc/sources: get: operationId: getDomainDmarcSources summary: Sending sources from DMARC aggregate reports security: - bearerAuth: [] description: | Requires `read` and the Growth plan or higher. Largest first. Without `class` only senders that are not Blindrelay are listed: a source is `blindrelay` when its IP is in Blindrelay's egress and DKIM `d=` or alignment matches; everything else is `unknown`, and other vendors are never guessed. parameters: - $ref: '#/components/parameters/DomainId' - name: class in: query schema: { type: string, enum: [unknown, blindrelay, all], default: unknown } - name: days in: query description: Lookback in days, clamped to 1 and the report retention (90 by default). schema: { type: integer, default: 30 } - name: limit in: query description: Clamped to 1-200. schema: { type: integer, default: 50 } - name: offset in: query schema: { type: integer, default: 0 } responses: '200': description: Sources content: application/json: schema: $ref: '#/components/schemas/DmarcSources' '400': description: '`invalid_payload`: unknown `class`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `dmarc_plan_required` on Free' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': $ref: '#/components/responses/DomainNotFound' /v1/domains/{id}/dns-guide: get: operationId: getDomainDnsGuide summary: Copy-paste LLM prompt for publishing this domain's DNS records security: - bearerAuth: [] description: | Requires `read`. Returns `{ prompt }` with the same SPF, DKIM, and DMARC values as GET domain, formatted for a DNS agent. Does not write DNS. parameters: - $ref: '#/components/parameters/DomainId' responses: '200': description: '`{ prompt }` plain-text guide' content: application/json: schema: type: object required: [prompt] properties: prompt: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/domains/{id}/verify: post: operationId: verifyDomain summary: Re-check SPF/DKIM/DMARC and the optional bounce CNAME security: - bearerAuth: [] description: | Requires `write`. Returns domain detail including `dns_checks` per record (`spf`, `dkim`, `dmarc`, `bounce`: `verified`, optional `code` `missing`|`incorrect`, and `message`) and `bounce_record`, the recommended `bounce.` CNAME. `bounce` never blocks sending; once verified, the Return-Path uses `bounce.` so SPF aligns with the From domain. parameters: - $ref: '#/components/parameters/DomainId' responses: '200': description: Updated domain detail (same shape as GET domain, including dns_checks) '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/domains/{id}/rotate-dkim: post: operationId: rotateDkim summary: Rotate DKIM key security: - bearerAuth: [] description: | Requires `write`. The old primary key stays valid as `retiring` for 7 days. Without `selector` a new `br{YYYYMMDD}` selector is chosen. parameters: - $ref: '#/components/parameters/DomainId' requestBody: content: application/json: schema: type: object properties: selector: type: string description: Optional custom DKIM selector responses: '200': description: New DKIM key '400': description: '`invalid_payload`: the selector is not a valid DNS label' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' '409': description: '`selector_in_use`: the domain already has a key with this selector' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/domains/{id}/dedicated-ip: put: operationId: setDomainDedicatedIp summary: Pin domain egress to a dedicated IP, shared pool, or workspace default security: - bearerAuth: [] description: | Requires `write`. Body `{ assignment_id?, use_shared }`. `use_shared: true` forces the shared pool (cannot combine with `assignment_id`). Omit `assignment_id` and set `use_shared: false` to follow the workspace default. parameters: - $ref: '#/components/parameters/DomainId' requestBody: required: true content: application/json: schema: type: object properties: assignment_id: type: [string, 'null'] format: uuid use_shared: type: boolean responses: '200': description: Updated domain summary including dedicated_ip_assignment_id '400': description: '`invalid_payload`: `use_shared: true` combined with `assignment_id`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': description: '`domain_not_found`, or `dedicated_ip_not_found` for an unknown or foreign `assignment_id`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`invalid_status_transition`: the assignment is not live yet' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/usage: get: operationId: getUsage summary: Current period usage security: - bearerAuth: [] description: Requires `read`. Messages counted in the current UTC month, per domain. Outbound sends and forwarded inbound mail share one total; `inbound_count` is the inbound share. responses: '200': description: Usage summary content: application/json: schema: type: object required: [period, total_count, inbound_count, quota, plan, by_domain] properties: period: { type: string, example: '2026-11' } total_count: type: integer description: Outbound sends and forwarded inbound messages. inbound_count: type: integer description: Forwarded inbound messages, included in `total_count`. quota: type: integer description: Monthly quota in effect (plan quota or the workspace override). plan: { type: string } spend_cap: oneOf: - $ref: '#/components/schemas/SpendCap' - type: 'null' description: The workspace's monthly message cap; `null` on Free and unlimited plans. by_domain: type: array items: type: object required: [domain, count, inbound_count] properties: domain: { type: string } count: { type: integer } inbound_count: { type: integer } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/spend-cap: get: operationId: getSpendCap summary: Workspace monthly message cap security: - bearerAuth: [] description: Requires `read`. `403 plan_restricted` on Free and unlimited plans, which have no billed overage. responses: '200': description: The cap and its bounds content: application/json: schema: $ref: '#/components/schemas/SpendCap' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' put: operationId: setSpendCap summary: Set or clear the workspace monthly message cap security: - bearerAuth: [] description: >- Requires `write`. A cap from the included quota to the 150% hard stop limits overage spend: sends past it return `429 spending_cap_reached`. `null` removes it. A later plan change clamps the cap to the new bounds on read. `400 invalid_payload` outside the bounds, `403 plan_restricted` on Free and unlimited plans, `403 admin_required` when the key's creator is not a workspace owner or admin now. requestBody: required: true content: application/json: schema: type: object required: [monthly_message_cap] properties: monthly_message_cap: type: [integer, 'null'] description: Monthly messages; `null` removes the cap. responses: '200': description: The saved cap and its bounds content: application/json: schema: $ref: '#/components/schemas/SpendCap' '400': description: Cap outside the bounds (`invalid_payload`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/usage/history: get: operationId: getUsageHistory summary: Usage history security: - bearerAuth: [] description: Requires `read`. One row per month and domain for the last 13 months; no paging. responses: '200': description: Usage history rows content: application/json: schema: type: array items: type: object required: [period, domain, message_count, inbound_count, quota] properties: period: { type: string, example: '2026-10' } domain: { type: string } message_count: type: integer description: Outbound sends and forwarded inbound messages. inbound_count: type: integer description: Forwarded inbound messages, included in `message_count`. quota: type: integer description: The current monthly quota (not the quota of that month). '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/delivery/stats: get: operationId: getDeliveryStats summary: Delivery counters security: - bearerAuth: [] description: | Requires `read`. Counters only (no bodies or recipient addresses). Totals for the period plus a zero-filled `series` built from the hourly counters: one bucket per UTC hour for periods up to 72 hours, one per UTC day beyond. `pending` is in the totals only and only without filters. `egress_keys` lists every value `egress_key` accepts for this workspace in the period, independent of the filters. parameters: - name: period_hours in: query description: Lookback in hours, clamped to 1–2160. Defaults to the envelope retention. schema: type: integer minimum: 1 maximum: 2160 - name: domain in: query description: Sending domain to count. Wins over `egress_key` when both are set. schema: type: string - name: egress_key in: query description: '`shared`, or a `key` from `egress_keys` (a dedicated IP inventory id).' schema: type: string responses: '200': description: Delivery stats content: application/json: schema: $ref: '#/components/schemas/DeliveryStats' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/emails: get: operationId: listEmails summary: Recent messages security: - bearerAuth: [] description: | Requires `send` or `read`. Lists this workspace's messages for the delivery data window (`lookup_window_hours`), newest activity first: messages still in flight and finished ones. Each row has the message id, sending domain, status and timestamps; no recipient, subject or body, and there is no search by recipient. Pages are stable while new mail arrives (a message moves up when a later report changes its status). Rate limited per workspace, separately from status lookups (`RATE_LIMIT_TENANT_EMAIL_LISTS_PER_HOUR`, default 600, shared with the dashboard). parameters: - name: status in: query description: Only this status (see `EmailStatus`). schema: type: string - name: domain in: query description: Only this sending domain. schema: type: string - name: page_size in: query description: Rows per page. Values above 100 are treated as 100. schema: type: integer minimum: 1 maximum: 100 default: 50 - name: cursor in: query description: '`next_cursor` of the previous page.' schema: type: string responses: '200': description: One page of messages content: application/json: schema: $ref: '#/components/schemas/EmailList' '400': description: Unknown status (`invalid_status`), domain (`invalid_domain`) or cursor (`invalid_cursor`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: The API key has neither the `send` nor the `read` scope (`scope_missing`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '429': $ref: '#/components/responses/RateLimited' /v1/emails/{id}: get: operationId: getEmailStatus summary: Delivery status of one message security: - bearerAuth: [] description: | Requires `send` or `read`. Looks a message up by the `id` its send returned (the `envelope_id` in webhooks); there is no lookup by recipient. Only the key's own workspace is searched. The response carries no recipient, subject or body, and nothing is stored or logged. Statuses are kept for the delivery data window (`lookup_window_hours`): an unknown id, another workspace's id and an id older than the window all answer `404 email_not_found`, which cannot be told apart. Lookups are rate limited per workspace (`RATE_LIMIT_TENANT_EMAIL_STATUS_LOOKUPS_PER_HOUR`, default 3600, shared with the dashboard). parameters: - name: id in: path required: true description: Message id from the `202` send response (one per recipient). schema: type: string format: uuid responses: '200': description: Current status content: application/json: schema: $ref: '#/components/schemas/EmailStatus' '400': description: The id is not a UUID '401': $ref: '#/components/responses/Unauthorized' '403': description: The API key has neither the `send` nor the `read` scope (`scope_missing`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: scope_missing message: api key needs the 'send' or 'read' scope '404': description: Unknown in this workspace, or older than the lookup window (`email_not_found`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: email_not_found message: No message with this id in this workspace, or it is older than the 72-hour lookup window. lookup_window_hours: 72 '429': $ref: '#/components/responses/RateLimited' /v1/emails/check: post: operationId: checkEmail summary: Check an email before sending (static preflight) security: - bearerAuth: [] description: | Requires `send` or `read`. Runs the shared offline rules (the same as `blindrelay check`, the MCP `email_check` tool and the dashboard Email check page) on `subject`, `html`, `text` and `from` (the `/v1/send` field names; omitted fields are skipped). Finds placeholder or broken links, unresolved template variables and common HTML mistakes. Never fetches a link: reset, verification and unsubscribe links can trigger actions. The content is checked in request memory and never stored or logged, and messages never quote it; a finding points at its place with `location`. Needs no verified domain and does not count against the send quota. Fields over the `/v1/send` limits (html and text 1 MiB, subject 998 characters, from 320) answer `413 payload_too_large`. Rate limited per workspace (`RATE_LIMIT_TENANT_EMAIL_CHECKS_PER_HOUR`, default 1200, shared with the dashboard). Codes (severity): `unresolved_template_variable` (error), `link_empty` (error), `link_javascript` (error), `link_placeholder` (error), `link_relative` (error), `link_insecure` (warning), `unclosed_tag` (error), `mismatched_tag` (error), `stray_closing_tag` (error), `unclosed_comment` (error), `script_tag` (error), `iframe_tag` (error), `form_tag` (warning), `external_stylesheet` (warning), `img_missing_alt` (warning), `text_alternative_missing` (warning), `subject_empty` (warning), `html_too_large` (warning, over about 102 KB Gmail clips), `body_missing` (error), `from_placeholder` (error). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailCheckRequest' example: subject: Reset your password html: '

Hi {{ name }}, reset

' responses: '200': description: Findings (`ok` is false when any error-severity finding exists) content: application/json: schema: $ref: '#/components/schemas/EmailCheckReport' '400': $ref: '#/components/responses/InvalidPayload' '401': $ref: '#/components/responses/Unauthorized' '403': description: The API key has neither the `send` nor the `read` scope (`scope_missing`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '413': description: A field is over its limit (`payload_too_large`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: payload_too_large message: html is over the 1048576 byte limit '429': $ref: '#/components/responses/RateLimited' /v1/suppression: get: operationId: listSuppression summary: List suppression entries security: - bearerAuth: [] description: | Requires `read`. Entries expose ids, reasons and rejection codes only (no addresses, no hashes, no remote reply text). Newest first, paged with `page` / `page_size`; `total` counts the entries matching `reason`, `summary` the whole list. parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/PageSize' - name: reason in: query required: false description: Only entries with this reason (empty means all). schema: type: string enum: [hard_bounce, soft_bounce, complaint, manual_suppression] responses: '200': description: One page of the suppression list content: application/json: schema: $ref: '#/components/schemas/SuppressionList' '400': description: Unknown `reason` (`invalid_reason`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' post: operationId: addSuppression summary: Add a hash-only suppression entry security: - bearerAuth: [] description: | Requires `write`. Accepts plaintext `address` (hashed bcrypt cost 12 in RAM) or a precomputed bcrypt `to_hash` (`to_hash` wins when both are given). Never persists the plaintext address. The entry's reason is `manual_suppression`. Adding the same address again within 24 hours returns the first entry (`201` again) instead of a second one. requestBody: required: true content: application/json: schema: type: object properties: address: type: string format: email to_hash: type: string description: bcrypt hash (`$2…`) of the address. responses: '201': description: Suppression entry (id, reason and codes; no address or hash) content: application/json: schema: $ref: '#/components/schemas/SuppressionEntry' '400': description: | `invalid_payload` (neither `address` nor `to_hash`), `invalid_address` (no `@`) or `invalid_hash` (`to_hash` is not a bcrypt hash). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/suppression/export: get: operationId: exportSuppression summary: Export suppression list security: - bearerAuth: [] description: | Requires `read`. Each row has `to_hash`, `reason`, `rejection_category`, `smtp_code`, `enhanced_status` and `created_at` (CSV columns in that order; absent codes are empty cells). parameters: - name: format in: query schema: type: string enum: [json, csv] default: json responses: '200': description: Export body (a JSON array of rows, or CSV with a header row) content: application/json: schema: type: array items: type: object properties: to_hash: { type: string } reason: { type: string } rejection_category: { type: [string, 'null'] } smtp_code: { type: [integer, 'null'] } enhanced_status: { type: [string, 'null'] } created_at: { type: string, format: date-time } text/csv: schema: type: string '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/suppression/lookup: post: operationId: lookupSuppression summary: Find every suppression entry for an address security: - bearerAuth: [] description: | Requires `read`. The address is sent in the body (never the URL), bcrypt-verified against the workspace's newest entries in process, and never stored or logged. An address has one entry; only entries recorded before per-address keys existed can add more, so remove each match by id. Lookups are rate limited per workspace (`RATE_LIMIT_TENANT_SUPPRESSION_LOOKUPS_PER_HOUR`, default 60). requestBody: required: true content: application/json: schema: type: object required: [address] properties: address: type: string format: email responses: '200': description: Lookup result content: application/json: schema: type: object required: [suppressed, entries, checked, complete] properties: suppressed: type: boolean entries: type: array description: Every matching entry, newest first. items: $ref: '#/components/schemas/SuppressionEntry' entry: description: Deprecated; the first of `entries`, or null. oneOf: - $ref: '#/components/schemas/SuppressionEntry' - type: 'null' checked: type: integer description: How many of the workspace's newest entries were verified (at most 200). complete: type: boolean description: False when the workspace has more entries than one lookup verifies. '400': description: Not a valid email address (`invalid_address`) '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '429': $ref: '#/components/responses/RateLimited' /v1/suppression/{id}: delete: operationId: removeSuppression summary: Remove a suppression entry security: - bearerAuth: [] description: | Requires `write`. Any reason can be removed. Hard bounces need `confirm=true`; complaints need `confirm=true` and `confirm_complaint=true` (the recipient reported the mail as spam). A removed entry is audited as `suppression.removed` with its id, reason and rejection codes. parameters: - name: id in: path required: true schema: type: string format: uuid - name: confirm in: query schema: type: boolean default: false description: Required for `hard_bounce` and `complaint` entries. - name: confirm_complaint in: query schema: type: boolean default: false description: Also required for `complaint` entries. responses: '204': description: Removed '400': description: | Missing confirmation: `confirmation_required` (hard bounce or complaint) or `complaint_confirmation_required` (complaint). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': description: No such entry in this workspace /v1/audit: get: operationId: listAudit summary: List tenant audit events security: - bearerAuth: [] description: | Requires `read`. Control-plane events only — no message content. Newest first, paged with `page` / `page_size`; `total` counts the events matching the filters. A `from` / `to` that is not a `YYYY-MM-DD` date is ignored. `retention_days` is how long events are kept. parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/PageSize' - name: event in: query required: false description: Only this event name (for example `domain.removed`). schema: type: string - name: from in: query required: false description: First UTC day included. schema: type: string format: date - name: to in: query required: false description: Last UTC day included. schema: type: string format: date responses: '200': description: Audit page content: application/json: schema: $ref: '#/components/schemas/AuditPage' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/feedback: post: operationId: sendFeedback summary: Send product feedback security: - bearerAuth: [] description: | Any scope. Product feedback for the Blindrelay team, read on the operator console. Agents (Agent Plugin `send_feedback`) send it only after the user approved the draft. Text holding anything shaped like an email address is refused with `400 contains_email_address`, whose message names the field and never echoes the value, so no recipient address or message content is stored. Every API key and session of a workspace share one hourly budget (`RATE_LIMIT_TENANT_FEEDBACK_PER_HOUR`, default 20). Entries are deleted 12 months after they were sent. The answer never echoes the text. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FeedbackRequest' responses: '201': description: Stored content: application/json: schema: $ref: '#/components/schemas/FeedbackReceipt' '400': description: | `invalid_payload` (a value outside its list, a text outside 20 to 2000 characters, a missing field) or `contains_email_address` (the message names `message` or `context`). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: contains_email_address message: context must not contain an email address; describe the case without it '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /v1/otlp: get: operationId: getOtlp summary: Get OTLP export config security: - bearerAuth: [] description: | Requires `read`. Auth header is never returned plaintext (preview only). With `domain_id`, that domain's override (`otlp_override: false` and empty values when it has none). parameters: - name: domain_id in: query required: false schema: type: string format: uuid responses: '200': description: OTLP config content: application/json: schema: $ref: '#/components/schemas/OtlpConfig' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' put: operationId: saveOtlp summary: Save OTLP export config security: - bearerAuth: [] description: | Requires `write`. Omit `auth_header` to keep stored secret; empty string clears. An empty `endpoint` turns export off (for `domain_id`: drops the override, as does `clear_otlp_override: true`). `protocol` is `grpc` or `http`; any other value saves `http`. requestBody: required: true content: application/json: schema: type: object required: [endpoint, protocol] properties: endpoint: type: string protocol: type: string enum: [http, grpc] auth_header: type: string description: '`Header-Name: value` (for example `Authorization: Bearer `), or `Bearer ` for the Authorization header.' domain_id: type: string format: uuid clear_otlp_override: type: boolean responses: '200': description: Saved OTLP config content: application/json: schema: $ref: '#/components/schemas/OtlpConfig' '400': description: '`invalid_otlp_auth_header`: `auth_header` is not a valid HTTP header line' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/otlp/test: post: operationId: testOtlp summary: Probe OTLP collector security: - bearerAuth: [] description: | Requires `write`. Probes using the request body (`endpoint`, `protocol`, `auth_header`, `domain_id`, as for `PUT /v1/otlp`; saved values fill what is omitted). Does not persist settings — use `PUT /v1/otlp` to save. A collector that cannot be reached or refuses the export answers `502 otlp_unreachable` with the collector's reason in `message` (unlike the delivery-hook test, which reports failures in a `200`). requestBody: content: application/json: schema: type: object properties: endpoint: { type: string } protocol: { type: string, enum: [http, grpc] } auth_header: { type: string } domain_id: { type: string, format: uuid } responses: '200': description: Collector reachable content: application/json: schema: type: object required: [success, message] properties: success: { type: boolean, enum: [true] } message: { type: string, example: Collector reachable } '400': description: | `otlp_not_configured` (no endpoint given or saved), `invalid_otlp_auth_header` or `invalid_otlp_config`. content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' '502': description: '`otlp_unreachable`: the collector could not be reached or refused the export.' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': $ref: '#/components/responses/InternalError' /v1/delivery-settings: get: operationId: getDeliverySettings summary: Get delivery webhook settings security: - bearerAuth: [] description: | Requires `read`. Webhook auth header is never returned plaintext. `diagnostics` holds content-free attempt counters for this scope's hook (the workspace hook, or the `domain_id` override); saving a different hook URL restarts them. `has_webhook_signing_secret` is false for a webhook set up before signing existed: its requests are unsigned until `POST /v1/delivery-settings/rotate-signing-secret` is called. parameters: - name: domain_id in: query required: false schema: type: string format: uuid responses: '200': description: Delivery settings content: application/json: schema: $ref: '#/components/schemas/DeliverySettings' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' put: operationId: saveDeliverySettings summary: Save delivery webhook settings security: - bearerAuth: [] description: | Requires `write`. Workspace saves require `enforce_soft_bounce_suppression`, unless `delivery_retry_enabled` is sent alone. Soft bounce and delivery retries are workspace-only (rejected on domain override). Saving a different hook URL resets that scope's `diagnostics`. Omit `webhook_auth_header` to keep the stored one; an empty string clears it. With `domain_id`, `clear_webhook_override: true` drops the domain's webhook override. requestBody: required: true content: application/json: schema: type: object properties: enforce_soft_bounce_suppression: type: boolean description: Required without `domain_id`; rejected with it. delivery_retry_enabled: type: boolean description: | Workspace-wide; rejected with `domain_id`. True: mail that cannot be delivered immediately waits on disk for up to 24 hours and is deleted once delivered or expired. False: mail stays in memory and a temporary failure is final. Omit to keep the stored value; sent alone it changes only this setting. webhook_url: type: [string, 'null'] webhook_auth_header: type: [string, 'null'] domain_id: type: string format: uuid clear_webhook_override: type: boolean responses: '200': description: Saved delivery settings content: application/json: schema: $ref: '#/components/schemas/DeliverySettings' '400': description: | `missing_field` (no `enforce_soft_bounce_suppression` on a workspace save), `soft_bounce_workspace_only` or `delivery_retry_workspace_only` (given with `domain_id`) or `plan_restricted` (the Free plan cannot turn soft-bounce suppression off). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/delivery-settings/test: post: operationId: testDeliverySettings summary: Send a test delivery webhook security: - bearerAuth: [] description: | Requires `write`. Sends a test POST using the request body (saved auth when omitted). Does not persist settings — use `PUT /v1/delivery-settings` to save. The probe outcome always answers 200: `success: false` with the same `category` and `http_status` as the attempt diagnostics when the endpoint fails. Tests are not counted as attempts. With no URL in the body and none saved (for the workspace, or for `domain_id`) it answers `400 hook_not_configured`. responses: '200': description: Probe outcome content: application/json: schema: $ref: '#/components/schemas/DeliveryHookTestResult' '400': description: '`hook_not_configured`: no webhook URL saved or given' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/delivery-settings/rotate-signing-secret: post: operationId: rotateDeliverySigningSecret summary: Generate or rotate a delivery webhook signing secret security: - bearerAuth: [] description: | Requires `write`. Returns the new secret once. On a webhook without a secret this generates its first one. Otherwise, for 24 hours from this call each request also carries a `v1=` signature made with the replaced secret. Each webhook request carries `Blindrelay-Signature: t=,v1=`, the hex HMAC-SHA256 of `..`, and `Blindrelay-Delivery-Id`, the same for every attempt of one event. A failed POST (network error, 5xx, 429) is retried with exponential backoff, 12 attempts over about 24 hours; any other 4xx ends it. requestBody: required: false content: application/json: schema: type: object properties: domain_id: type: [string, 'null'] format: uuid description: The domain override's hook. Omit for the workspace hook. responses: '200': description: Store `signing_secret` now; no later call returns it content: application/json: schema: $ref: '#/components/schemas/DeliverySigningSecret' '400': description: '`hook_not_configured`: this scope has no webhook URL' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DomainNotFound' /v1/dedicated-ip: get: operationId: getDedicatedIp summary: Dedicated IP assignments and eligibility security: - bearerAuth: [] description: Requires `read`. Same payload as session `GET /api/dedicated-ip`. responses: '200': description: >- Eligibility, assignments, default_assignment_id, optional recent_closure. `assignments[].inventory.blocklist` is null until the first check, otherwise `{ checked_at, listed_zones, context_listed_zones, errored_zones }`. It covers only bl.spamcop.net, b.barracudacentral.org and dnsbl-1.uceprotect.net; `context_listed_zones` (dnsbl-2 and dnsbl-3.uceprotect.net) are informational. A zone in `errored_zones` could not be checked and is not a clean result; if it is also in a listed field, that field holds its last known result. `assignments[].warmup` is null until the assignment is live, otherwise `{ warm, daily_limit, sent_today, overflowed_today, exempt }`. The limit follows the IP's own send history; `daily_limit` is null when the IP is warm or exempt. Mail above the daily limit uses the shared pool and counts in `overflowed_today`. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/dedicated-ip/request: post: operationId: requestDedicatedIp summary: Request a dedicated IP security: - bearerAuth: [] description: Requires `write`. Self-serve returns `checkout_url`; otherwise queues for ops. Paid plans are for business use under the Terms (confirmed at sign-up). The Checkout records `business_use=confirmed_at_signup` in its Stripe metadata. A second request while one is open answers `409 dedicated_ip_in_flight`; two at the same moment, `409 action_in_progress`. responses: '201': description: Assignment created (with `checkout_url` when payment is due) '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `plan_not_eligible` (Free, Mini and Starter)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': $ref: '#/components/responses/DedicatedIpFailure' '502': $ref: '#/components/responses/DedicatedIpFailure' '503': $ref: '#/components/responses/DedicatedIpFailure' /v1/dedicated-ip/dismiss-closure: post: operationId: dismissDedicatedIpClosure summary: Dismiss a dedicated IP closure notice security: - bearerAuth: [] description: Requires `write`. requestBody: required: true content: application/json: schema: type: object required: [assignment_id] properties: assignment_id: type: string format: uuid responses: '204': description: Dismissed '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DedicatedIpFailure' /v1/dedicated-ip/{id}/cancel: post: operationId: cancelDedicatedIp summary: Cancel a dedicated IP assignment security: - bearerAuth: [] description: Requires `write`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Cancelled assignment (a repeated cancel returns it unchanged) '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DedicatedIpFailure' '409': $ref: '#/components/responses/DedicatedIpFailure' '502': $ref: '#/components/responses/DedicatedIpFailure' /v1/dedicated-ip/{id}/resume-checkout: post: operationId: resumeDedicatedIpCheckout summary: Resume Stripe Checkout for a pending dedicated IP payment security: - bearerAuth: [] description: Requires `write`. Paid plans are for business use under the Terms (confirmed at sign-up). A new Checkout records `business_use=confirmed_at_signup` in its Stripe metadata. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: A new Checkout for the pending payment content: application/json: schema: type: object required: [checkout_url] properties: checkout_url: { type: string, format: uri } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DedicatedIpFailure' '409': $ref: '#/components/responses/DedicatedIpFailure' '502': $ref: '#/components/responses/DedicatedIpFailure' '503': $ref: '#/components/responses/DedicatedIpFailure' /v1/dedicated-ip/{id}/set-default: post: operationId: setDefaultDedicatedIp summary: Set workspace default dedicated IP security: - bearerAuth: [] description: Requires `write`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Default updated '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': $ref: '#/components/responses/DedicatedIpFailure' '409': $ref: '#/components/responses/DedicatedIpFailure' /v1/inbound-forward: get: operationId: getInboundForward summary: Inbound forward aliases (paid plans) security: - bearerAuth: [] description: Requires `read`. responses: '200': description: Eligibility, MX host, aliases and HTTPS endpoints content: application/json: schema: type: object required: [eligible, mailbox_eligible, mx_host, max_aliases, aliases, max_endpoints, endpoints] properties: eligible: type: boolean description: A paid plan; endpoint aliases and endpoint management are allowed. mailbox_eligible: type: boolean description: Also an `active` dedicated IP; aliases may forward to a mailbox. mx_host: type: string max_aliases: type: integer aliases: type: array items: $ref: '#/components/schemas/InboundForwardAlias' max_endpoints: type: integer endpoints: type: array items: $ref: '#/components/schemas/InboundEndpoint' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/inbound-forward/aliases: post: operationId: createInboundForwardAlias summary: Create an inbound forward alias security: - bearerAuth: [] description: > Requires `write` and an `active` dedicated IP. Give exactly one of `destination` (forward-to mailbox) and `endpoint_id` (an HTTPS endpoint of this workspace); both or neither answer `400 invalid_destination`, as does a mailbox equal to the alias's own address; a foreign or unknown endpoint answers `404 endpoint_not_found`. 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 destination: type: string description: Forward-to mailbox. endpoint_id: type: string format: uuid description: Deliver to this HTTPS endpoint instead of a mailbox. responses: '201': description: Alias created content: application/json: schema: $ref: '#/components/schemas/InboundForwardAlias' '400': description: '`invalid_local_part` or `invalid_destination`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, `paid_plan_required` (Free), or `inbound_forward_dedicated_ip_required` (a mailbox destination without an `active` dedicated IP)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: '`domain_not_found` or `endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`domain_not_verified`, `alias_limit` or `alias_exists`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/inbound-forward/aliases/{id}: patch: operationId: updateInboundForwardAlias summary: Update an inbound forward alias security: - bearerAuth: [] description: Requires `write`. parameters: - name: id in: path required: true schema: type: string format: uuid requestBody: content: application/json: schema: type: object properties: destination: type: string description: Switch the alias to this mailbox. endpoint_id: type: string format: uuid description: Switch the alias to this HTTPS endpoint (not with `destination`). enabled: type: boolean responses: '200': description: Updated alias content: application/json: schema: $ref: '#/components/schemas/InboundForwardAlias' '400': description: '`invalid_destination`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, `paid_plan_required` (Free), or `inbound_forward_dedicated_ip_required` (a mailbox destination without an `active` dedicated IP)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: '`alias_not_found` or `endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteInboundForwardAlias summary: Delete an inbound forward alias security: - bearerAuth: [] description: Requires `write`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Deleted '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' '404': description: '`alias_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/inbound-forward/endpoints: get: operationId: listInboundEndpoints summary: List inbound HTTPS endpoints security: - bearerAuth: [] description: > Requires `read`. Never returns a signing secret. `diagnostics` holds content-free delivery attempt counters (the delivery-hook shape). responses: '200': description: Endpoints content: application/json: schema: type: object required: [max_endpoints, endpoints] properties: max_endpoints: type: integer endpoints: type: array items: $ref: '#/components/schemas/InboundEndpoint' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' post: operationId: createInboundEndpoint summary: Create an inbound HTTPS endpoint security: - bearerAuth: [] description: > Requires `write` and an `active` dedicated IP. `url` must be https and not resolve to a private or internal address (`400 invalid_endpoint_url`); `name` is 1–80 printable characters (`400 invalid_endpoint_name`). At most 5 per workspace (`409 endpoint_limit`, also under concurrent creates); names are unique (`409 endpoint_exists`). The response carries `signing_secret` once; it is never returned again. Delivery contract (signature, retries, bounces): docs/design/inbound-https-delivery.md. requestBody: required: true content: application/json: schema: type: object required: [name, url] properties: name: type: string maxLength: 80 url: type: string format: uri maxLength: 2048 responses: '201': description: Created; store `signing_secret` now content: application/json: schema: $ref: '#/components/schemas/InboundEndpointSecret' '400': description: '`invalid_endpoint_url` or `invalid_endpoint_name`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`endpoint_limit` or `endpoint_exists`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `paid_plan_required` (Free)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/inbound-forward/endpoints/{id}: patch: operationId: updateInboundEndpoint summary: Rename an inbound endpoint or change its URL security: - bearerAuth: [] description: > Requires `write` and an `active` dedicated IP. A new URL zeroes the diagnostics. Only the name and URL change; the signing secrets are never written by this call, so it cannot undo a concurrent rotation. `400 invalid_endpoint_name` / `invalid_endpoint_url`, `409 endpoint_exists`. parameters: - name: id in: path required: true schema: type: string format: uuid requestBody: content: application/json: schema: type: object properties: name: type: string maxLength: 80 url: type: string format: uri responses: '200': description: Updated endpoint content: application/json: schema: $ref: '#/components/schemas/InboundEndpoint' '400': description: '`invalid_endpoint_url` or `invalid_endpoint_name`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: '`endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`endpoint_exists`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `paid_plan_required` (Free)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteInboundEndpoint summary: Delete an inbound endpoint security: - bearerAuth: [] description: > Requires `write`. `409 endpoint_in_use` while an alias delivers to it. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Deleted '404': description: '`endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`endpoint_in_use`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ScopeMissing' /v1/inbound-forward/endpoints/{id}/rotate-secret: post: operationId: rotateInboundEndpointSecret summary: Rotate an endpoint signing secret security: - bearerAuth: [] description: > Requires `write` and an `active` dedicated IP. Returns the new secret once. For 24 hours from this call each delivery carries a second `v1=` signature made with the replaced secret; a second rotation inside that window makes the secret it replaces the previous one and restarts the 24 hours. A rotation that races another one answers `409 endpoint_rotation_conflict`; reload and rotate again if needed. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Rotated; store `signing_secret` now content: application/json: schema: $ref: '#/components/schemas/InboundEndpointSecret' '404': description: '`endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': description: '`endpoint_rotation_conflict`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `paid_plan_required` (Free)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/inbound-forward/endpoints/{id}/test: post: operationId: testInboundEndpoint summary: Send a test delivery to an endpoint security: - bearerAuth: [] description: > Requires `write` and an `active` dedicated IP. Posts a fixed sample message (no user content) with `Blindrelay-Test: 1`, signed like a real delivery. Answers `200` with the result either way; not counted in the diagnostics. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Test result content: application/json: schema: $ref: '#/components/schemas/DeliveryHookTestResult' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`scope_missing`, or `paid_plan_required` (Free)' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: '`endpoint_not_found`' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /health: get: operationId: health summary: Liveness check description: | Returns `200` when the API process is running. Use for uptime monitors that only need to know the service is alive (no dependency checks). security: [] responses: '200': description: Service is alive content: application/json: schema: $ref: '#/components/schemas/HealthResponse' example: status: ok version: 1.0.0 source_commit: 486ee83c4e6f31ec7d471fd41f56988a88992e2c source_tree: 5d3cba32b239f951ce56c62346422976f576aa91 release_id: release-5d3cba32b239f951ce56c62346422976f576aa91 /ready: get: operationId: ready summary: Readiness check description: | Returns `200` when the API accepts new sends. Returns `503` during deploy drain when new sends are rejected with code `draining`. Use for load balancer health checks that should stop routing traffic during graceful shutdown. security: [] responses: '200': description: Ready to accept sends content: application/json: schema: $ref: '#/components/schemas/ReadyResponse' example: ready: true '503': description: Not ready (deploy drain active) content: application/json: schema: $ref: '#/components/schemas/ReadyResponse' example: ready: false reason: draining components: securitySchemes: bearerAuth: type: http scheme: bearer description: | Tenant API key (`br_` + alphanumeric). Set env `BLINDRELAY_API_KEY`. The key must include the scope required by the operation (see scope matrix). parameters: Page: name: page in: query required: false description: 1-based page number; values below 1 count as 1. schema: type: integer minimum: 1 default: 1 PageSize: name: page_size in: query required: false description: Entries per page, clamped to 1–100. schema: type: integer minimum: 1 maximum: 100 default: 50 DomainId: name: id in: path required: true schema: type: string format: uuid IdempotencyKey: name: Idempotency-Key in: header required: false description: | Optional. Duplicate requests with the same key within 24 hours return the original `202` response without re-sending. Use one key per logical send. schema: type: string maxLength: 256 example: reset-2024-06-29-user-42 WriteIdempotencyKey: name: Idempotency-Key in: header required: false description: | Session routes only (web UI). One key per user intent, 1-64 characters of letters, digits, `-` or `_` (the UI sends a UUID). Replayed responses carry `Idempotent-Replayed: true`. schema: type: string maxLength: 64 pattern: '^[A-Za-z0-9_-]{1,64}$' example: 3f2b8c1e-9d4a-4b7e-8f00-1234567890ab schemas: FeedbackRequest: type: object required: [sentiment, area, message, context] properties: sentiment: type: string enum: [positive, neutral, negative] area: type: string enum: [sending, domains, dmarc, inbound, dedicated_ip, billing, cli, agent_plugin, docs, other] message: type: string minLength: 20 maxLength: 2000 description: What worked surprisingly well or badly; for a problem, steps to reproduce and desired versus observed result. No email addresses. context: type: string minLength: 20 maxLength: 2000 description: What the user was trying to do. No email addresses. actor: type: string enum: [human, agent] default: agent FeedbackReceipt: type: object required: [id, created_at] properties: id: type: string format: uuid created_at: type: string format: date-time DmarcReportingSetup: type: object required: [rua_address, points_at_us] properties: rua_address: { type: string, description: 'Address for the `rua` tag of this domain, `@rua.`' } points_at_us: { type: [boolean, 'null'], description: 'Whether the last DNS check found this address in the DMARC record; null before any check' } DmarcReadiness: type: object required: [status, window_days, reports, report_days, blindrelay_messages, aligned_messages, aligned_share, aligned_days, would_break_sources, would_break_messages, top_would_break, recommendation] properties: status: { type: string, enum: [insufficient_data, alignment_issues, review_sources, candidate] } window_days: { type: integer } reports: { type: integer } report_days: { type: integer } blindrelay_messages: { type: integer } aligned_messages: { type: integer } aligned_share: { type: [number, 'null'] } aligned_days: { type: integer, description: 'Days with reports of fully aligned Blindrelay mail, counted back from the newest' } would_break_sources: { type: integer } would_break_messages: { type: integer } top_would_break: type: array items: type: object required: [source_ip, messages] properties: source_ip: { type: string } messages: { type: integer } recommendation: { type: string } DmarcSummary: type: object required: [domain_id, domain, retention_days, setup, window_days, reports, reporters, total_messages, blindrelay_messages, unknown_messages, last_report_at, readiness] properties: domain_id: { type: string, format: uuid } domain: { type: string } retention_days: { type: integer } setup: { $ref: '#/components/schemas/DmarcReportingSetup' } window_days: { type: integer } reports: { type: integer } reporters: { type: integer } total_messages: { type: integer } blindrelay_messages: { type: integer } unknown_messages: { type: integer } last_report_at: { type: [string, 'null'], format: date-time } readiness: { $ref: '#/components/schemas/DmarcReadiness' } DmarcSources: type: object required: [domain_id, window_days, class, total, limit, offset, sources] properties: domain_id: { type: string, format: uuid } window_days: { type: integer } class: { type: string, enum: [unknown, blindrelay, all] } total: { type: integer } limit: { type: integer } offset: { type: integer } sources: type: array items: type: object required: [source_ip, class, messages, dkim_aligned_messages, spf_aligned_messages, would_break_messages, header_from, reporters, first_seen, last_seen, alerted_at] properties: source_ip: { type: string } class: { type: string, enum: [blindrelay, unknown] } messages: { type: integer } dkim_aligned_messages: { type: integer } spf_aligned_messages: { type: integer } would_break_messages: { type: integer, description: Messages that fail both alignments from a source that is not Blindrelay } header_from: { type: array, items: { type: string } } reporters: { type: integer } first_seen: { type: string, format: date-time } last_seen: { type: string, format: date-time } alerted_at: { type: [string, 'null'], format: date-time } HookOutcome: type: string description: | Delivery-hook outcome, classified from the transport error kind or the HTTP status only. `ok`: 2xx. `rejected_url`: URL refused before sending (private address, bad scheme). `connect_failed`: DNS or TCP connect failed. `timeout`: no answer in time. `tls`: TLS handshake or certificate failure. `auth_failed`: 401 or 403. `auth_not_enforced` (connection test only): the endpoint accepted a request without or with a wrong auth header. `invalid_auth_header`: the saved auth header is not a valid HTTP header. `http_3xx` / `http_4xx` / `http_5xx`: other non-2xx answers (redirects are not followed). `client_error`: any other client-side failure. enum: - ok - rejected_url - connect_failed - timeout - tls - auth_failed - auth_not_enforced - invalid_auth_header - http_3xx - http_4xx - http_5xx - client_error HookDiagnostics: type: object description: | Content-free attempt counters for one hook. No URL, payload, envelope id or response body. required: - attempts_total - failures_total - counting_since - last_attempt_at - last_success_at - last_failure_at - last_http_status - last_outcome - last_failure_category properties: attempts_total: type: integer failures_total: type: integer counting_since: type: [string, 'null'] format: date-time description: First attempt, or the save that last changed the hook URL. Null before any attempt. last_attempt_at: type: [string, 'null'] format: date-time last_success_at: type: [string, 'null'] format: date-time last_failure_at: type: [string, 'null'] format: date-time last_http_status: type: [integer, 'null'] description: HTTP status of the latest attempt; null when no HTTP answer arrived. last_outcome: oneOf: - $ref: '#/components/schemas/HookOutcome' - type: 'null' last_failure_category: oneOf: - $ref: '#/components/schemas/HookOutcome' - type: 'null' OtlpConfig: type: object required: [endpoint, protocol, has_auth_header, auth_header_preview, scope, domain_id, otlp_override] properties: endpoint: type: string description: Empty when export is off. protocol: type: string enum: [http, grpc] has_auth_header: type: boolean auth_header_preview: type: [string, 'null'] description: Masked preview; the header itself is never returned. scope: type: string enum: [workspace, domain] domain_id: type: [string, 'null'] format: uuid otlp_override: type: boolean DeliverySettings: type: object properties: enforce_soft_bounce_suppression: type: boolean soft_bounce_suppression_locked: type: boolean delivery_retry_enabled: type: boolean description: | Workspace-wide, default true. On: mail that cannot be delivered immediately waits on disk for up to 24 hours and is deleted once delivered or expired. Off: mail stays in memory and a temporary failure is final. webhook_url: type: [string, 'null'] has_webhook_auth_header: type: boolean webhook_auth_header_preview: type: [string, 'null'] scope: type: string enum: [workspace, domain] domain_id: type: [string, 'null'] format: uuid webhook_override: type: boolean has_webhook_signing_secret: type: boolean description: False for a webhook set up before signing existed; its requests are unsigned. webhook_previous_secret_expires_at: type: [string, 'null'] format: date-time description: Until when the replaced secret also signs; null outside a rotation overlap. webhook_secret_rotated_at: type: [string, 'null'] format: date-time webhook_signing_secret: type: string description: Only in the response of the save that first configured the webhook. Shown once. diagnostics: $ref: '#/components/schemas/HookDiagnostics' DeliverySigningSecret: type: object required: [signing_secret, previous_secret_expires_at, secret_rotated_at, scope, domain_id] properties: signing_secret: type: string description: Shown once. previous_secret_expires_at: type: [string, 'null'] format: date-time description: When the replaced secret stops signing; null when there was none. secret_rotated_at: type: [string, 'null'] format: date-time scope: type: string enum: [workspace, domain] domain_id: type: [string, 'null'] format: uuid DeliveryHookTestResult: type: object required: [success, message, category, http_status] properties: success: type: boolean message: type: string description: Human-readable result; never echoes the URL or the response body. category: $ref: '#/components/schemas/HookOutcome' http_status: type: [integer, 'null'] InboundForwardAlias: type: object required: [id, domain_id, domain, local_part, destination_kind, destination, endpoint_id, enabled, address] properties: id: type: string format: uuid domain_id: type: string format: uuid domain: type: string local_part: type: string destination_kind: type: string enum: [mailbox, endpoint] destination: type: string description: > Forward-to mailbox; `""` for an endpoint alias (kept a string so clients released before endpoints keep decoding; read `destination_kind`). endpoint_id: type: [string, 'null'] format: uuid description: HTTPS endpoint; null for a mailbox alias. enabled: type: boolean address: type: string InboundEndpoint: type: object description: Customer HTTPS endpoint for inbound aliases. Never carries the signing secret. required: [id, name, url, alias_count, created_at, updated_at, secret_rotated_at, previous_secret_expires_at, diagnostics] properties: id: type: string format: uuid name: type: string url: type: string alias_count: type: integer created_at: type: string format: date-time updated_at: type: string format: date-time secret_rotated_at: type: [string, 'null'] format: date-time previous_secret_expires_at: type: [string, 'null'] format: date-time description: While set, deliveries also carry a signature made with the replaced secret. diagnostics: $ref: '#/components/schemas/HookDiagnostics' InboundEndpointSecret: type: object required: [endpoint, signing_secret] properties: endpoint: $ref: '#/components/schemas/InboundEndpoint' signing_secret: type: string description: > `brsig_` + 64 hex characters, shown once. Verify `Blindrelay-Signature: t=,v1=[,v1=]` where v1 = hex(HMAC-SHA256(secret, "..." + raw body)), with a 5-minute timestamp tolerance. EmailCheckRequest: type: object description: Same field names as `SendRequest`; every field is optional and an omitted one is not checked. properties: subject: type: string maxLength: 998 html: type: string description: At most 1 MiB. text: type: string description: At most 1 MiB. from: type: string maxLength: 320 description: Sender address, checked for a placeholder domain. EmailCheckReport: type: object required: [ok, error_count, warning_count, truncated, findings] properties: ok: type: boolean description: No error-severity finding; warnings do not fail the check. error_count: type: integer description: All errors, including any beyond the listed findings. warning_count: type: integer truncated: type: boolean description: More than 100 findings exist; only the first 100 are listed. findings: type: array maxItems: 100 items: $ref: '#/components/schemas/EmailCheckFinding' example: ok: false error_count: 2 warning_count: 1 truncated: false findings: - code: unresolved_template_variable severity: error field: html message: 'Unresolved template variable ({{ … }}): render the template before sending.' location: { line: 1, column: 7 } - code: link_placeholder severity: error field: html message: '`` points to a placeholder or local address.' location: { line: 1, column: 28 } - code: text_alternative_missing severity: warning field: text message: 'No plain-text part: Blindrelay derives one from the HTML, but a written one reads better in text-only clients.' location: null EmailCheckFinding: type: object required: [code, severity, field, message, location] properties: code: type: string description: Stable snake_case rule code (listed on `POST /v1/emails/check`). severity: type: string enum: [error, warning] field: type: string enum: [subject, from, html, text] message: type: string description: What is wrong and how to fix it. Never quotes the checked content. location: type: [object, 'null'] description: 1-based line and character column in `field`; null when the finding is about the field as a whole. properties: line: type: integer minimum: 1 column: type: integer minimum: 1 FailedBreakdown: type: object required: [soft_bounce, hard_bounce, failed, suppressed] properties: soft_bounce: { type: integer, minimum: 0 } hard_bounce: { type: integer, minimum: 0 } failed: { type: integer, minimum: 0 } suppressed: { type: integer, minimum: 0 } DeliveryStatsBucket: type: object description: Terminal outcomes in one bucket; `failed` is the sum of `failed_breakdown`. required: [bucket_start, relay_accepted, delivered, failed, failed_breakdown] properties: bucket_start: type: string format: date-time description: UTC start of the hour or day. relay_accepted: { type: integer, minimum: 0 } delivered: { type: integer, minimum: 0 } failed: { type: integer, minimum: 0 } failed_breakdown: $ref: '#/components/schemas/FailedBreakdown' EgressKeyOption: type: object required: [key, label, kind] properties: key: type: string description: Value for the `egress_key` filter. label: type: string example: Dedicated 203.0.113.7 kind: type: string enum: [shared, dedicated, platform] ipv4: type: string description: Dedicated IPv4, when known. DeliveryStats: type: object required: - pending - relay_accepted - delivered - failed - failed_breakdown - retention_hours - period_hours - series_resolution - series - egress_keys properties: pending: { type: integer, minimum: 0 } relay_accepted: { type: integer, minimum: 0 } delivered: { type: integer, minimum: 0 } failed: { type: integer, minimum: 0 } failed_breakdown: $ref: '#/components/schemas/FailedBreakdown' retention_hours: { type: integer } period_hours: { type: integer } domain: { type: string } egress_key: { type: string } series_resolution: type: string enum: [hour, day] series: type: array description: Zero-filled buckets across the period, oldest first. items: $ref: '#/components/schemas/DeliveryStatsBucket' egress_keys: type: array description: Shared first, then each dedicated IP the workspace sent through or held in the period. items: $ref: '#/components/schemas/EgressKeyOption' EmailListItem: type: object description: One message; carries no recipient, subject or body. required: [id, status, summary, final, domain, api_key_id, accepted_at, updated_at, sandbox] properties: id: type: string format: uuid status: type: string description: Same names as `EmailStatus.status`. summary: type: string final: type: boolean domain: type: [string, 'null'] description: Sending domain. Null when none was recorded for the message. api_key_id: type: [string, 'null'] format: uuid description: API key that sent it. Null for SMTP submissions or after the key was deleted. accepted_at: type: [string, 'null'] format: date-time description: When Blindrelay accepted the message; null when not recorded. updated_at: type: string format: date-time description: Time of the latest status change. sandbox: type: boolean description: Accepted under a sandbox API key; nothing was delivered. EmailList: type: object required: [emails, next_cursor, lookup_window_hours] properties: emails: type: array items: $ref: '#/components/schemas/EmailListItem' next_cursor: type: [string, 'null'] description: Pass back as `cursor` for the next page; null on the last page. lookup_window_hours: type: integer description: How long messages stay listed, in hours (`ENVELOPE_TTL_HOURS`). EmailStatus: type: object required: [id, status, summary, final, accepted_at, updated_at, lookup_window_hours, sandbox] properties: id: type: string format: uuid status: type: string description: | Named like the webhook events. `queued`: accepted, waiting for or in its delivery attempt. `relay_accepted`: handed to the receiving mail server; a later report can still change it. `delivered`: the recipient's server accepted it. `soft_bounce`: temporary failure, your app may resend. `hard_bounce`: permanent failure, recipient suppressed. `complaint`: the recipient reported it as spam. `suppressed`: not sent, the recipient is on the suppression list. `failed`: the delivery attempt could not complete. enum: - queued - relay_accepted - delivered - soft_bounce - hard_bounce - complaint - suppressed - failed summary: type: string description: One human-readable sentence about the status. final: type: boolean description: | True for `delivered`, `hard_bounce`, `complaint`, `suppressed` and `failed`. False while a later event can still change the status (`queued`, `relay_accepted`, `soft_bounce`). accepted_at: type: [string, 'null'] format: date-time description: When the send was accepted; null only when no acceptance time was recorded. updated_at: type: string format: date-time description: Time of the latest status change. lookup_window_hours: type: integer minimum: 1 description: How long statuses are kept after the send. sandbox: type: boolean description: Accepted under a sandbox API key; nothing was delivered. example: id: 550e8400-e29b-41d4-a716-446655440000 status: delivered summary: Blindrelay delivered the message. final: true accepted_at: null updated_at: '2026-06-29T10:15:02Z' lookup_window_hours: 72 sandbox: false SuppressionEntry: type: object required: [id, reason, rejection_category, smtp_code, enhanced_status, created_at] properties: id: type: string format: uuid reason: type: string enum: [hard_bounce, soft_bounce, complaint, manual_suppression] rejection_category: description: | Why the remote server rejected the mail, from its codes only. `invalid_recipient` is the only category that creates a permanent `hard_bounce`; `policy_or_authentication` suppresses nothing; `mailbox_full`, `transient` and `unknown` are soft bounces. Null on complaints, manual entries and bounce entries recorded before causes were stored. type: [string, 'null'] enum: [invalid_recipient, policy_or_authentication, mailbox_full, transient, unknown, null] smtp_code: type: [integer, 'null'] example: 550 enhanced_status: description: RFC 3463 enhanced status code, e.g. `5.1.1`. type: [string, 'null'] example: 5.1.1 created_at: type: string format: date-time SendEmailRequest: type: object required: - from - to - subject - html properties: from: type: string format: email maxLength: 320 description: Sender address (must match a verified sending domain) example: noreply@example.com to: description: > Recipient address, or an array of up to 50 addresses. Duplicate addresses (case-insensitive) are collapsed. One envelope and one SMTP RCPT per address. Not a campaign/blast API. oneOf: - type: string format: email maxLength: 320 - type: array minItems: 1 maxItems: 50 items: type: string format: email maxLength: 320 example: user@example.com subject: type: string minLength: 1 maxLength: 998 html: type: string minLength: 1 maxLength: 1048576 description: HTML body (max 1 MB) text: type: string description: > Plain-text alternative. Blindrelay always sends multipart/alternative (text + html): when `text` is omitted or blank it derives the text part from the HTML. Pass your own for full control over the plain version. headers: type: object additionalProperties: type: string description: > Extra headers (key-value map), e.g. `Reply-To`, `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058, needed for one-click unsubscribe at Gmail/Yahoo) on every message with an unsubscribe link, using the same signed per-recipient URL as the footer; that URL must accept POST with no sign-in, confirmation page or CSRF check, idempotently, answering 2xx (a GET only shows a confirm button). Leave them off strictly transactional mail and mandatory notices. Headers Blindrelay sets itself (From, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Content-*, Return-Path, DKIM-Signature, Received, Sender) and values containing line breaks are ignored. List-*, Reply-To and Feedback-ID are DKIM-signed. cc: description: > Additional recipients shown in the `Cc` header. Bare addresses only. Shares the 50 address limit with `to` and `bcc`. One envelope and one quota unit per address (more when the message has attachments). oneOf: - type: string format: email maxLength: 320 - type: array maxItems: 50 items: type: string format: email maxLength: 320 bcc: description: > Additional recipients never shown in any header of any copy. Bare addresses only. Shares the 50 address limit with `to` and `cc`. Addresses also listed in `to` or `cc` are dropped here. One envelope and one quota unit per address. oneOf: - type: string format: email maxLength: 320 - type: array maxItems: 50 items: type: string format: email maxLength: 320 tags: type: array maxItems: 5 description: > Opaque labels returned as a `tags` object on this message's webhook events. The character sets exclude `@` and spaces, so a tag cannot hold an email address. Stored with the message's delivery metadata until the normal expiry; not exported to metrics. items: type: object required: [key, value] additionalProperties: false properties: key: type: string pattern: '^[a-z0-9_]{1,32}$' value: type: string pattern: '^[A-Za-z0-9_.-]{1,64}$' attachments: type: array maxItems: 10 description: > Files to attach (paid plans; Free answers `403 plan_restricted`). At most 10 files and 10,240,000 decoded bytes in total. Refused with `400`: `too_many_attachments`, `attachment_too_large`, `invalid_attachment_base64`, `invalid_attachment_filename`, `invalid_attachment_content_type`. items: $ref: '#/components/schemas/SendEmailAttachment' SendEmailAttachment: type: object required: - filename - content_type - content_base64 properties: filename: type: string minLength: 1 maxLength: 255 description: File name; no control characters. Non-ASCII names are RFC 2231 encoded on the wire. content_type: type: string minLength: 1 maxLength: 255 description: '`type/subtype`, optionally with parameters. `multipart/*` and `message/*` are refused.' example: application/pdf content_base64: type: string description: Standard base64 of the file bytes, without line breaks. SendEmailResponse: type: object required: - id - status properties: id: type: string format: uuid description: First envelope id (compat) ids: type: array items: type: string format: uuid description: One envelope id per recipient copy, including `cc` and `bcc` copies; unique `to` addresses first, then `cc`, then `bcc` status: type: string enum: [queued] description: Initial status after accept warnings: type: array description: | Soft deliverability warnings about the body (codes only, never content). Omitted when there are none; the message is queued either way. `dev_url_in_body`: a loopback or plain-http URL in `html` / `text`. `missing_text_alternative`: no `text` part. `cta_host_from_mismatch`: the links point at a host unrelated to the From domain. An `Idempotency-Key` replay returns the warnings of the first answer. items: type: string enum: [dev_url_in_body, missing_text_alternative, cta_host_from_mismatch] ErrorBody: type: object required: - code - message properties: code: type: string description: | Every code the HTTP API answers with, on `/v1/*` and on the session (`/api/*`, `/auth/*`) and operator (`/ops/*`) routes; each operation lists the ones it can return. Treat an unknown code as its HTTP status (new codes may be added). Not covered: the MCP OAuth token and registration endpoints, which answer the RFC 6749 `{"error", "error_description"}` shape. enum: - action_in_progress - admin_required - alias_exists - alias_limit - alias_not_found - already_consumed - attachment_too_large - billing_cancel_failed - billing_error - billing_not_configured - body_read_timeout - cannot_cancel - cannot_delete_assigned - cannot_retire - claim_already_reviewed - complaint_confirmation_required - confirm_required - confirmation_required - dedicated_ip_in_flight - dedicated_ip_not_found - dedicated_ip_operation_failed - delivery_retry_workspace_only - dmarc_plan_required - domain_limit_reached - domain_not_found - domain_not_verified - draining - email_domain_blocked - email_not_found - email_taken - endpoint_exists - endpoint_in_use - endpoint_limit - endpoint_not_found - endpoint_rotation_conflict - expired - forbidden - founding_coupon_not_configured - founding_partner_already_claimed - founding_partner_closed - founding_partner_full - has_pending_envelopes - hook_not_configured - idempotency_in_progress - idempotency_key_reused - inbound_forward_dedicated_ip_required - internal - invalid_address - invalid_attachment_base64 - invalid_attachment_content_type - invalid_attachment_filename - invalid_challenge - invalid_credential - invalid_cursor - invalid_destination - invalid_domain - invalid_email - invalid_endpoint_name - invalid_endpoint_url - invalid_hash - invalid_idempotency_key - invalid_ipv4 - invalid_key - invalid_local_part - invalid_offer - invalid_otlp_auth_header - invalid_otlp_config - invalid_payload - invalid_plan - invalid_provider - invalid_query - invalid_reason - invalid_request - invalid_signature - invalid_signup_source - invalid_state - invalid_status - invalid_status_transition - invalid_tag - invalid_token - invalid_workspace_name - inventory_not_found - inventory_unavailable - ipv4_required - last_workspace - limit_reached - member_exists - missing_field - missing_signature - missing_smtp_credentials - name_confirmation_mismatch - no_account - no_active_subscription - no_inventory_available - not_configured - not_found - not_pending - otlp_not_configured - otlp_unreachable - owner_must_transfer - ownership_changed - paid_plan_required - passkey_required - password_login_removed - payload_too_large - payment_failed - plan_change_blocked - plan_change_pending - plan_not_eligible - plan_restricted - plan_unchanged - price_not_configured - provisioning_incomplete - ptr_not_ready - queue_at_capacity - quota_exceeded - rate_limit_exceeded - rate_limited - reason_required - recipient_suppressed - refund_required - release_cooldown_active - release_not_eligible - release_signals_dirty - release_wrong_status - sandbox_scope_restricted - scope_missing - selector_in_use - signup_disabled - smtp_not_editable_on_free_pool - soft_bounce_workspace_only - spending_cap_reached - stripe_customer_missing - stripe_error - stripe_subscription_required - subscription_ambiguous - tenant_not_found - tenant_suspended - terms_not_accepted - too_many_attachments - tos_required - unauthorized - unknown_tenant - warmup_limit_exceeded - workspace_limit_reached message: type: string retry_after_secs: type: integer minimum: 1 description: Present on `429 rate_limited`; same value as the `Retry-After` header. lookup_window_hours: type: integer minimum: 1 description: Present on `404 email_not_found`; how long message statuses are kept. SetupStatus: type: object required: [workspace, caller, complete, next_step, domain, steps, plan_restrictions, missing_scopes] properties: workspace: type: object required: [name, plan] properties: name: { type: string } plan: { type: string } caller: type: object required: [kind] properties: kind: type: string enum: [api_key, session] scopes: type: array items: { type: string, enum: [send, read, write] } description: Scopes of the calling key (`api_key` only) role: type: string description: Workspace role (`session` only) complete: type: boolean next_step: type: [string, 'null'] description: First step not done, in step order domain: description: The domain the domain steps refer to (a verified one when it exists, else the newest) oneOf: - type: 'null' - type: object required: [id, domain, status] properties: id: { type: string, format: uuid } domain: { type: string } status: { type: string } steps: type: array items: $ref: '#/components/schemas/SetupStep' plan_restrictions: type: array items: type: object required: [code, message, steps] properties: code: type: string enum: [plan_restricted, domain_limit_reached, quota_exceeded] message: { type: string } steps: type: array items: { type: string } limit: { type: integer } used: { type: integer } missing_scopes: type: array items: { type: string, enum: [send, read, write] } description: Scopes the calling key lacks for the steps not done (empty for sessions) example: workspace: { name: Acme, plan: free } caller: { kind: api_key, scopes: [send] } complete: false next_step: dns_published domain: { id: 2f1c0c3e-3b8f-4a52-9d55-0b7a4c9e6a11, domain: mail.acme.dev, status: pending } steps: - id: connected title: Connect an app or agent with an API key done: true missing_scopes: [] next_action: null - id: dns_published title: Publish the DNS records done: false blocked_reason: API keys on the Free plan carry only the send scope. Register and verify the domain in the dashboard, or upgrade to Mini for read and write keys. blocked_code: plan_restricted missing_scopes: [read, write] next_action: summary: Publish the SPF, DKIM and DMARC records at your DNS provider, then run verification. ui_path: /domains ui_url: https://blindrelay.app/domains cli: - blindrelay domains dns-guide 2f1c0c3e-3b8f-4a52-9d55-0b7a4c9e6a11 - blindrelay domains verify 2f1c0c3e-3b8f-4a52-9d55-0b7a4c9e6a11 mcp_tools: [domains_dns_guide, domains_verify] required_scopes: [read, write] pending_records: - record: dkim type: TXT host: br202611._domainkey.mail.acme.dev value: v=DKIM1; k=rsa; p=MIIB... code: missing message: No DKIM TXT record found plan_restrictions: - code: plan_restricted message: API keys on the Free plan carry only the send scope. Register and verify the domain in the dashboard, or upgrade to Mini for read and write keys. steps: [dns_published, domain_verified] missing_scopes: [read, write] SetupStep: type: object required: [id, title, done, missing_scopes, next_action] properties: id: type: string enum: [connected, domain_registered, dns_published, domain_verified, test_send] title: { type: string } done: { type: boolean } blocked_reason: { type: string } blocked_code: { type: string } missing_scopes: type: array items: { type: string } next_action: description: How to finish the step; `null` once done oneOf: - type: 'null' - type: object required: [summary, ui_path, ui_url, cli, mcp_tools, required_scopes] properties: summary: { type: string } ui_path: { type: string } ui_url: { type: string, format: uri } cli: type: array items: { type: string } mcp_tools: type: array items: { type: string } required_scopes: type: array items: { type: string } pending_records: type: array description: '`dns_published` only: required records the last check did not find' items: type: object required: [record, type, host, value] properties: record: { type: string, enum: [spf, dkim, dmarc] } type: { type: string } host: { type: string } value: { type: string } code: { type: [string, 'null'], description: '`missing`, `incorrect`, or null when never checked' } message: { type: [string, 'null'] } SuppressionList: type: object required: [summary, entries, total, page, page_size] properties: summary: type: object required: [total, by_reason] description: Counts over the whole list, independent of `reason` and paging. properties: total: { type: integer } by_reason: type: object required: [hard_bounce, soft_bounce, complaint, manual_suppression] properties: hard_bounce: { type: integer } soft_bounce: { type: integer } complaint: { type: integer } manual_suppression: { type: integer } entries: type: array items: $ref: '#/components/schemas/SuppressionEntry' total: type: integer description: Entries matching `reason` (all entries without it). page: { type: integer } page_size: { type: integer } AuditPage: type: object required: [entries, total, page, page_size, retention_days] properties: entries: type: array items: type: object required: [id, timestamp, event, resource_type, resource_id, actor, actor_type, ip_address] properties: id: { type: string, format: uuid } timestamp: { type: string, format: date-time } event: { type: string } resource_type: type: string description: '`—` when the event has none.' resource_id: type: string description: '`—` when the event has none.' actor: type: string description: Member e-mail, API key prefix (`br_…`) or `system`. actor_type: type: string enum: [user, api_key, system] ip_address: type: string description: Empty when not recorded. total: { type: integer } page: { type: integer } page_size: { type: integer } retention_days: type: integer description: How long workspace audit events are kept. SpendCap: type: object required: [monthly_message_cap, minimum, maximum] properties: monthly_message_cap: type: [integer, 'null'] description: The cap as enforced now; `null` when the workspace set none. minimum: type: integer description: Lowest accepted cap (the included monthly quota). maximum: type: integer description: Highest accepted cap (the 150% hard stop). ErrorResponse: type: object required: - error properties: error: $ref: '#/components/schemas/ErrorBody' HealthResponse: type: object required: - status - version - source_commit - source_tree - release_id properties: status: type: string enum: [ok] version: type: string source_commit: type: string description: Original tested source commit, preserved through squash promotion source_tree: type: string description: Full source tree used by this build release_id: type: string description: Immutable release- identity ReadyResponse: type: object required: - ready properties: ready: type: boolean reason: type: string description: Present when ready is false (e.g. draining) responses: RateLimited: description: A request throttle tripped (not a product budget) headers: Retry-After: schema: type: integer description: Seconds until the throttle window frees up content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: rate_limited message: Too many requests. Please try again later. retry_after_secs: 42 InvalidPayload: description: Validation failed content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: invalid_payload message: invalid recipient address Unauthorized: description: Missing or invalid API key content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: unauthorized message: invalid api key DedicatedIpFailure: description: | A dedicated IP change that cannot happen now. `404 dedicated_ip_not_found` (unknown id or another workspace's), `403 plan_not_eligible` (Free, Mini or Starter), `409` with `dedicated_ip_in_flight`, `invalid_status_transition`, `no_inventory_available`, `cannot_cancel`, `stripe_subscription_required`, `provisioning_incomplete` or `action_in_progress` (a concurrent change of the same assignment), `502 billing_error` / `dedicated_ip_operation_failed`, `503 price_not_configured`. content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' DomainNotFound: description: Unknown domain id, or one of another workspace (`domain_not_found`) content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: domain_not_found message: domain not found ScopeMissing: description: API key lacks the required scope content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: scope_missing message: api key missing 'read' scope InternalError: description: Unexpected server error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: error: code: internal message: internal error