openapi: 3.0.3 info: title: 2mail Provisioning API version: "1.0" description: > Onboard sending domains ("mailboxes"), issue per-application SMTP credentials, and manage pooled monthly-limit groups. **Auth:** admin/reseller API keys — `Authorization: Bearer 2m_live_…` (or HTTP Basic with username `api` and the key as password). Create keys in the 2mail admin panel. **Key scopes:** an *admin* key has full access; a *pool* key is restricted to one limit group — it can only create/list/manage mailboxes inside that pool (new mailboxes are forced into it), cannot create groups or move mailboxes between pools (`403`), and `GET /groups` returns only its pool. A *mailbox* key is **send-only**: `POST /messages` and `GET /messages/{id}` for one mailbox are the only routes it can reach — every other endpoint on this page returns `403`, including ones added later. Its `from` address must be one of that mailbox's allowed domains. Use it for an application that just sends mail and should never be able to provision anything. servers: - url: /2mail/api/v1 description: Same origin as the docs page security: - bearerAuth: [] tags: - name: Mailboxes - name: Credentials - name: Groups - name: Keys paths: /mailboxes: get: tags: [Mailboxes] summary: List mailboxes description: Admin keys list all mailboxes; pool keys list only their pool's mailboxes. responses: "200": description: OK content: application/json: schema: type: object properties: mailboxes: type: array items: { $ref: "#/components/schemas/Mailbox" } "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [Mailboxes] summary: Create a mailbox (sending domain) description: > Creates the mailbox and its first SMTP credential (password shown once). For a pool key, `group_id` is ignored and forced to the key's pool, and `monthly_limit` is ignored — the pool's shared limit applies (the returned mailbox shows the pool limit). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/MailboxCreate" } example: domain: acme.com from_name: Acme from_email: news@acme.com allowed_domains: acme.com,shop.acme.com monthly_limit: 100000 credential_label: primary responses: "201": description: Created content: application/json: schema: type: object properties: mailbox: { $ref: "#/components/schemas/Mailbox" } credential: { $ref: "#/components/schemas/CredentialWithPassword" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } /mailboxes/{id}: parameters: - $ref: "#/components/parameters/MailboxId" get: tags: [Mailboxes] summary: Get a mailbox responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Mailbox" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /mailboxes/{id}/usage: parameters: - $ref: "#/components/parameters/MailboxId" get: tags: [Mailboxes] summary: Monthly usage (pooled if grouped) + per-credential breakdown responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Usage" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /mailboxes/{id}/dns: parameters: - $ref: "#/components/parameters/MailboxId" get: tags: [Mailboxes] summary: Domain authentication (SPF/DKIM/DMARC) records + verification status description: The DNS records the customer must publish, each with a verified flag. responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/DnsStatus" } example: domain: acme.com status: partial all_verified: false records: - { type: SPF, dns_type: TXT, host: acme.com, value: "v=spf1 a mx include:_spf.jaan.be ~all", verified: true, verified_at: "2026-08-14 09:00:00" } - { type: DKIM, dns_type: TXT, host: "default._domainkey.acme.com", value: "v=DKIM1; k=rsa; p=…", verified: false, verified_at: null } - { type: DMARC, dns_type: TXT, host: "_dmarc.acme.com", value: "v=DMARC1; p=quarantine; …", verified: false, verified_at: null } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /mailboxes/{id}/credentials: parameters: - $ref: "#/components/parameters/MailboxId" get: tags: [Credentials] summary: List a mailbox's SMTP credentials responses: "200": description: OK content: application/json: schema: type: object properties: credentials: type: array items: { $ref: "#/components/schemas/Credential" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Credentials] summary: Create an SMTP credential (one per application) requestBody: required: true content: application/json: schema: type: object required: [label] properties: label: { type: string, example: billing-app } responses: "201": description: Created (password shown once) content: application/json: schema: { $ref: "#/components/schemas/CredentialWithPassword" } "403": { $ref: "#/components/responses/Forbidden" } /mailboxes/{id}/credentials/{cid}: parameters: - $ref: "#/components/parameters/MailboxId" - name: cid in: path required: true schema: { type: integer } delete: tags: [Credentials] summary: Revoke a credential responses: "200": description: Deleted content: application/json: schema: type: object properties: deleted: { type: boolean } id: { type: integer } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /mailboxes/{id}/group: parameters: - $ref: "#/components/parameters/MailboxId" put: tags: [Mailboxes] summary: Assign the mailbox to a pool (limit group) description: Forbidden for pool-scoped keys. requestBody: required: true content: application/json: schema: type: object required: [group_id] properties: group_id: { type: integer, example: 3 } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Mailbox" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Mailboxes] summary: Remove the mailbox from its pool responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Mailbox" } "403": { $ref: "#/components/responses/Forbidden" } /groups: get: tags: [Groups] summary: List pooled limit groups description: Admin API keys only (403 for pool-scoped keys). responses: "200": description: OK content: application/json: schema: type: object properties: groups: type: array items: { $ref: "#/components/schemas/Group" } post: tags: [Groups] summary: Create a pooled limit group (admin keys only) description: Forbidden (403) for pool-scoped keys. requestBody: required: true content: application/json: schema: type: object required: [name, monthly_limit] properties: name: { type: string, example: Acme } monthly_limit: { type: integer, example: 100000 } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Group" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } /groups/{gid}/users: parameters: - name: gid in: path required: true schema: { type: integer } get: tags: [Groups] summary: List a pool's login grants description: > Pool grants — logins that can open every mailbox in this pool (present and future). Admin API keys only (403 for pool-scoped keys) — a machine key must not be able to grant control-panel login access. responses: "200": description: OK content: application/json: schema: type: object properties: users: type: array items: type: object properties: user_id: { type: integer } demo_mode: { type: boolean } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Groups] summary: Grant a login access to the whole pool description: > Give a login account (by user_id or email) access to every mailbox in this pool. Idempotent; re-posting updates the demo flag. requestBody: required: true content: application/json: schema: type: object properties: user_id: { type: integer, description: "v2_login uid (or use email)" } email: { type: string, description: "login email; resolved to a uid" } demo_mode: { type: boolean, default: false } responses: "201": description: Granted content: application/json: schema: type: object properties: group_id: { type: integer } user_id: { type: integer } demo_mode: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /groups/{gid}/users/{uid}: parameters: - name: gid in: path required: true schema: { type: integer } - name: uid in: path required: true schema: { type: integer } delete: tags: [Groups] summary: Revoke a login's pool grant responses: "200": description: Revoked content: application/json: schema: { type: object, properties: { ok: { type: boolean } } } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /messages: post: tags: [Messages] summary: Send an email (HTTP send) description: > Accepts a message and queues it; the relay delivers it asynchronously (so it is counted and bound by the pooled monthly limit, like SMTP/campaign sends). The `from` domain must be an allowed domain of a mailbox — and, for a pool-scoped key, of a mailbox in that pool. Returns 202 with a message id; poll GET /messages/{id} for delivery status. Provide `html`, `text`, or both. requestBody: required: true content: application/json: schema: type: object required: [from, to, subject] properties: from: { type: string, example: "Acme ", description: "email or \"Name \"" } to: description: One recipient, a comma-separated string, or an array (max 50 across to+cc+bcc). oneOf: - { type: string, example: user@dest.com } - { type: array, items: { type: string, format: email } } cc: description: Optional cc recipient(s) — string or array. oneOf: - { type: string } - { type: array, items: { type: string, format: email } } bcc: description: Optional bcc recipient(s) — string or array (stripped from delivered headers). oneOf: - { type: string } - { type: array, items: { type: string, format: email } } subject: { type: string, example: "Welcome" } html: { type: string, example: "

Hi

" } text: { type: string, example: "Hi" } from_name: { type: string, example: Acme } reply_to: { type: string, format: email } send_at: type: string description: "Optional scheduled delivery time (ISO 8601). Past/absent = send ASAP; max 30 days out." example: "2026-08-20T09:00:00Z" tracking: type: boolean description: > Open-tracking override. Omit to use the mailbox default (tracking_mode); true forces the tracking pixel on, false forces it off. Only applies to HTML bodies. responses: "202": description: Queued content: application/json: schema: type: object properties: id: { type: string, example: 4f9a…e1 } status: { type: string, enum: [queued, scheduled], example: queued } mailbox_id: { type: integer } scheduled_for: { type: string, nullable: true } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "413": { description: Message body too large } /messages/{id}: parameters: - name: id in: path required: true schema: { type: string } get: tags: [Messages] summary: Delivery status of a submitted message responses: "200": description: OK content: application/json: schema: type: object properties: id: { type: string } status: { type: string, enum: [queued, sending, sent, failed] } to: { type: string } mailbox_id: { type: integer } message_id: { type: string, nullable: true } error: { type: string, nullable: true } created_at: { type: string } sent_at: { type: string, nullable: true } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /events: get: tags: [Events] summary: Query send-pipeline events description: > Unified event log across the send pipeline (the pull equivalent of webhooks). Scope: `mailbox` (scope-checked) restricts to one mailbox; otherwise a pool key returns its pool's events and an admin key returns all. Defaults to the last 7 days. parameters: - { name: event, in: query, schema: { type: string, enum: [accepted, delivered, deferred, bounced, opened, rejected, failed] } } - { name: mailbox, in: query, schema: { type: integer }, description: restrict to one mailbox id } - { name: recipient, in: query, schema: { type: string }, description: exact recipient email } - { name: begin, in: query, schema: { type: string }, description: "start date (default: 7 days ago)" } - { name: end, in: query, schema: { type: string }, description: "end date (default: today)" } - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 300 } } responses: "200": description: OK content: application/json: schema: type: object properties: events: type: array items: type: object properties: event: { type: string } timestamp: { type: string } recipient: { type: string } mailbox_id: { type: integer } message_id: { type: string, nullable: true } detail: { type: string, nullable: true } code: { type: string, nullable: true } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /keys: get: tags: [Keys] summary: List API keys description: > Admin keys see every key; a pool key sees only the send-only mailbox keys of mailboxes in its own pool. The key value and its hash are never returned — only the prefix, for identification. responses: "200": description: OK content: application/json: schema: type: object properties: keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Keys] summary: Create an API key description: > A key can only hand out authority it already holds. An **admin** key may create `pool` and `mailbox` keys; a **pool** key may create `mailbox` keys only, and only for mailboxes inside its own pool. Creating an `admin` key is refused (`403`) — that stays a control-panel action. `label` is required. The full `key` is returned ONCE. requestBody: required: true content: application/json: schema: type: object required: [scope, label] properties: scope: { type: string, enum: [pool, mailbox] } label: { type: string, example: "Webshop orders" } mailbox_id: { type: integer, description: "required when scope=mailbox" } group_id: { type: integer, description: "required when scope=pool (admin keys only)" } responses: "201": description: Created content: application/json: schema: allOf: - $ref: "#/components/schemas/ApiKey" - type: object properties: key: { type: string, description: "shown once" } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /keys/{id}: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [Keys] summary: Get an API key responses: "200": description: OK content: { application/json: { schema: { $ref: "#/components/schemas/ApiKey" } } } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Keys] summary: Enable or disable an API key description: Pauses a key without revoking it. The key authenticating the request cannot modify itself (`400`). requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: { type: boolean } responses: "200": description: Updated content: { application/json: { schema: { $ref: "#/components/schemas/ApiKey" } } } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Keys] summary: Revoke an API key description: Immediate. The key authenticating the request cannot delete itself (`400`). responses: "200": description: Revoked content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /webhooks: get: tags: [Webhooks] summary: List webhook subscriptions description: Pool key → its own pool; admin → all (or `?group_id=`). Secret is never returned here. responses: "200": description: OK content: application/json: schema: type: object properties: webhooks: type: array items: type: object properties: id: { type: integer } group_id: { type: integer } url: { type: string } events: { type: array, items: { type: string } } enabled: { type: boolean } created_at: { type: string } post: tags: [Webhooks] summary: Create a webhook subscription description: > Register an http(s) URL to receive event pushes for a pool. `events` (subset of the GET /events types) empty/omitted = all. The `secret` is returned ONCE — use it to verify the `X-2mail-Signature: t=,v1=` header, where v1 = HMAC-SHA256(secret, `.`). Deliveries retry with backoff on non-2xx. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, example: "https://example.com/2mail/webhook" } events: { type: array, items: { type: string }, description: "subset of event types; empty = all" } group_id: { type: integer, description: "required for an admin key; ignored for a pool key" } responses: "201": description: Created content: application/json: schema: type: object properties: id: { type: integer } group_id: { type: integer } url: { type: string } events: { type: array, items: { type: string } } secret: { type: string, description: "shown once" } enabled: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /webhooks/{id}: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [Webhooks] summary: Get a webhook subscription description: Secret is never returned — rotate it with PATCH if it was lost. responses: "200": description: OK content: { application/json: { schema: { $ref: "#/components/schemas/Webhook" } } } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Webhooks] summary: Update a webhook subscription description: > Partial update — omitted fields keep their current value. Use `enabled` to pause deliveries without losing the subscription, and `rotate_secret` to issue a new signing key (returned ONCE in this response, like on create; the old key stops verifying immediately). The pool a webhook belongs to cannot be changed. requestBody: required: true content: application/json: schema: type: object properties: url: { type: string, example: "https://example.com/2mail/webhook" } events: { type: array, items: { type: string }, description: "subset of event types; empty = all" } enabled: { type: boolean } rotate_secret: { type: boolean, description: "issue a new signing key" } responses: "200": description: Updated content: application/json: schema: allOf: - $ref: "#/components/schemas/Webhook" - type: object properties: secret: { type: string, description: "only when rotate_secret was set — shown once" } "400": { $ref: "#/components/responses/BadRequest" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Webhooks] summary: Delete a webhook subscription responses: "200": description: Deleted content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /validate: post: tags: [Validation] summary: Validate a single email address description: > Syntax + MX (+ MX resolvability) check, disposable/role flags, and typo suggestions. Set `deep` for an SMTP RCPT probe (slower; may be inconclusive if port 25 is blocked). Returns `result` (deliverable|undeliverable|risky|unknown) with a human-readable `detail`. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email, example: user@example.com } deep: { type: boolean, default: false, description: "run the SMTP mailbox probe" } responses: "200": description: OK content: application/json: schema: type: object properties: email: { type: string } result: { type: string, enum: [deliverable, undeliverable, risky, unknown] } reason: { type: string } detail: { type: string } syntax: { type: boolean } mx: { type: boolean } mx_resolvable: { type: boolean, nullable: true } mx_hosts: { type: array, items: { type: string } } disposable: { type: boolean } role: { type: boolean } did_you_mean: { type: string, nullable: true } smtp: { type: boolean, nullable: true } smtp_code: { type: integer, nullable: true } smtp_message: { type: string, nullable: true } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: "API key as a Bearer token: 2m_live_…" parameters: MailboxId: name: id in: path required: true schema: { type: integer } responses: Unauthorized: description: Missing/invalid API key content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } Forbidden: description: Out of the key's scope content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } NotFound: description: Not found content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } BadRequest: description: Invalid input content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } Conflict: description: Conflict (e.g. duplicate group name) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object properties: error: { type: string } Mailbox: type: object properties: id: { type: integer } uuid: { type: string } domain: { type: string } identity: { type: string, description: from-email (fallback domain) } allowed_domains: { type: array, items: { type: string } } from_name: { type: string } from_email: { type: string } monthly_limit: { type: integer } group_id: { type: integer, nullable: true } smtp_enabled: { type: boolean } comment: { type: string, nullable: true } MailboxCreate: type: object required: [domain, from_name, from_email] properties: domain: { type: string } from_name: { type: string } from_email: { type: string, format: email } allowed_domains: { type: string, description: CSV; defaults to domain } monthly_limit: type: integer default: 50000 description: > Per-mailbox monthly send limit. IGNORED when the mailbox is pooled — i.e. whenever `group_id` is set, and ALWAYS for a pool-scoped key (which forces the mailbox into its pool). In that case the pool's shared limit applies and the returned mailbox reflects the pool limit, not this value. Only used for standalone mailboxes created with an admin key and no `group_id`. group_id: { type: integer, description: pool to join (overrides monthly_limit; forced for pool keys) } smtp_enabled: { type: boolean, default: true } comment: { type: string } credential_label: { type: string, default: primary } credential_username: { type: string, description: optional explicit SMTP login } Credential: type: object properties: id: { type: integer } username: { type: string } label: { type: string } enabled: { type: boolean } created_at: { type: string, nullable: true } last_used_at: { type: string, nullable: true } CredentialWithPassword: allOf: - $ref: "#/components/schemas/Credential" - type: object properties: password: { type: string, description: shown once } Group: type: object properties: id: { type: integer } name: { type: string } monthly_limit: { type: integer } ApiKey: type: object properties: id: { type: integer } prefix: { type: string, description: "first 12 chars, for identification" } label: { type: string, nullable: true } scope: { type: string, enum: [admin, pool, mailbox] } group_id: { type: integer, nullable: true, description: "set when scope=pool" } mailbox_id: { type: integer, nullable: true, description: "set when scope=mailbox" } enabled: { type: boolean } created_at: { type: string, nullable: true } last_used_at: { type: string, nullable: true } Webhook: type: object properties: id: { type: integer } group_id: { type: integer } url: { type: string } events: { type: array, items: { type: string }, description: "empty = all event types" } enabled: { type: boolean } created_at: { type: string, nullable: true } DnsStatus: type: object properties: domain: { type: string } status: { type: string, enum: [pending, partial, verified] } all_verified: { type: boolean } records: type: array items: type: object properties: type: { type: string, enum: [SPF, DKIM, DMARC] } dns_type: { type: string, example: TXT } host: { type: string, description: DNS name/host to create } value: { type: string, description: DNS record value } verified: { type: boolean } verified_at: { type: string, nullable: true } Usage: type: object properties: month: { type: string, example: "2026-08" } monthly_limit: { type: integer } monthly_used: { type: integer } monthly_left: { type: integer } pooled: { type: boolean } credentials: type: array items: type: object properties: id: { type: integer } username: { type: string } label: { type: string } enabled: { type: boolean } used_month: { type: integer }