# Download attachment Source: https://docs.openmail.sh/api-reference/attachments/download-attachment https://api.openmail.sh/openapi.json get /v1/attachments/{messageId}/{filename} Returns the attachment bytes. Authenticate with `Authorization: Bearer `. The response is the file itself (not a redirect), so clients can fetch the URL from `attachments[].url` with the same API key they use for the rest of the API. # Authentication Source: https://docs.openmail.sh/api-reference/authentication OpenMail uses API key authentication. Pass your secret key as a Bearer token in the Authorization header on every request to the REST API. All API requests require a Bearer token in the `Authorization` header: ``` Authorization: Bearer om_your_api_key_here ``` API keys are prefixed with `om_` for production. ## Getting an API key 1. Sign up at the [Dashboard](https://console.openmail.sh/login). You'll receive a magic link to sign in. 2. Complete the setup wizard: configure your webhook URL, then copy your API key and base URL. 3. Use the API key in the `Authorization` header for all API requests. See [Quickstart](/quickstart) for the full setup flow. ## Key scopes OpenMail has three kinds of API keys. All three authenticate the same way, as a Bearer token — they differ only in how much of your account they reach. | Key | Access | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Account-wide** | Everything. Can manage pods, inboxes, and [custom domains](/concepts/custom-domains), and mint or revoke scoped keys. This is the key you get at signup. | | **Pod-scoped** | One [pod](/concepts/pods) — reading and sending from its inboxes, creating more inboxes in it, and adding a custom domain scoped to it. Nothing in another pod. Cannot delete an inbox or change its webhook config, and no pod or key management. | | **Inbox-scoped** | One [inbox](/concepts/inboxes) — reading and sending, nothing else. No other inbox, and no creating, modifying, or deleting inboxes. | Neither scoped kind can touch [sender rules](/concepts/sender-rules): every `/v1/policy` call from a scoped key returns `403`, reads included. An agent cannot read or change the policy that applies to it. Pick the narrowest scope that fits. A pod-scoped key suits a tenant that owns several inboxes; an inbox-scoped key suits a single agent that owns exactly one. Mint them with `POST /v1/pods/{id}/api-keys` and `POST /v1/inboxes/{id}/api-keys` respectively. Pod keys require an account-wide key; inbox keys accept an account-wide key or a pod key for that inbox's pod. A scoped key can never widen its own reach, and each pod or inbox can hold up to 20 active keys. See [Pod-scoped API keys](/concepts/pods#pod-scoped-api-keys) and [Inbox-scoped API keys](/concepts/inboxes#inbox-scoped-api-keys). The full token is returned once, at creation, and cannot be retrieved again — store it securely. Listing keys returns only a masked prefix and the last 4 characters. ## Security * Keep your API key secret. Do not expose it in client-side code. * Prefer a scoped key when an integration only needs one pod's inboxes, or one inbox. Revoke either any time with `DELETE /v1/pods/{id}/api-keys/{keyId}` or `DELETE /v1/inboxes/{id}/api-keys/{keyId}` — revocation is immediate. * Deleting an inbox revokes its inbox-scoped keys along with it. * If you believe your account-wide key has been compromised, contact support immediately for rotation. ### Scoping limits what a compromised agent can reach An agent that reads email processes untrusted input. A message body can carry instructions aimed at the agent rather than at you — a prompt injection — and a convincing one can get the agent to make API calls you never intended. Scoping decides how far those calls reach. The key is the boundary, so it holds whatever the agent's reasoning does: * An **inbox-scoped** key confines the damage to the one inbox that received the message. Other inboxes, other tenants, and your account settings stay out of reach — the agent cannot read them, send from them, or delete them. It also cannot read or change the inbox's [sender rules](/concepts/sender-rules), so who it may write to stays fixed regardless of what the injected instructions say. * An **account-wide** key on the same agent turns one malicious email into account-wide reach. Give each agent an inbox-scoped key for the inbox it owns. Keep the account-wide key server-side, and use it only to provision and to mint the scoped keys. ## Invalid authentication ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} // 401 Unauthorized { "error": "unauthorized", "message": "Missing or invalid Authorization header" } ``` # Add custom domain Source: https://docs.openmail.sh/api-reference/domains/add-custom-domain https://api.openmail.sh/openapi.json post /v1/domains Add a custom domain to your account. The response includes the DNS records to publish (`records`). Once you publish them, the domain verifies automatically within a couple of minutes — poll `GET /v1/domains/{id}` until `status` is `verified`, then create inboxes on it. A pod-scoped API key creates the domain scoped to its own pod; inbox-scoped keys cannot manage domains. # Delete custom domain Source: https://docs.openmail.sh/api-reference/domains/delete-custom-domain https://api.openmail.sh/openapi.json delete /v1/domains/{id} Remove a custom domain from your account. The domain must have no inboxes — delete those first. This does not remove the DNS records from your registrar. An account-wide key may delete any domain; a pod-scoped key may delete only a domain scoped to its own pod, never an account-wide domain. Inbox-scoped keys cannot manage domains. # Get custom domain Source: https://docs.openmail.sh/api-reference/domains/get-custom-domain https://api.openmail.sh/openapi.json get /v1/domains/{id} Retrieve a single custom domain by its ID, including its verification `status` and the DNS records to publish. Poll this after adding a domain to watch it flip to `verified`. # List custom domains Source: https://docs.openmail.sh/api-reference/domains/list-custom-domains https://api.openmail.sh/openapi.json get /v1/domains List the custom domains on your account. A pod-scoped key sees only its pod's domains plus account-wide ones. # Re-check custom domain Source: https://docs.openmail.sh/api-reference/domains/re-check-custom-domain https://api.openmail.sh/openapi.json post /v1/domains/{id}/verify Force an immediate DNS re-check of a custom domain, instead of waiting for the automatic background check. The domain verifies on its own within a couple of minutes of you publishing the records, so this is only needed to speed that up. An account-wide key may re-check any domain; a pod-scoped key may re-check only a domain scoped to its own pod, never an account-wide domain. Inbox-scoped keys cannot manage domains. # Report feedback Source: https://docs.openmail.sh/api-reference/feedback/report-feedback https://api.openmail.sh/openapi.json post /v1/feedback Report a bug, a point of friction, or a feature request directly to the OpenMail team. If an API call fails unexpectedly, a response looks wrong, or you notice something that would make OpenMail work better for you, send it here — one call, no confirmation needed, and the team reads every report. Works even while an inbox or pod is suspended. Include what you were trying to do and what you observed; attach the endpoint, error code, and request id in `context` when the report concerns a specific failed call. # Create inbox Source: https://docs.openmail.sh/api-reference/inboxes/create-inbox https://api.openmail.sh/openapi.json post /v1/inboxes Create a new email inbox. Optionally set a sender display name. # Create inbox API key Source: https://docs.openmail.sh/api-reference/inboxes/create-inbox-api-key https://api.openmail.sh/openapi.json post /v1/inboxes/{id}/api-keys Mint an API key scoped to this inbox. The key can only read and send from this one inbox — its threads, messages, and drafts — and can never reach another inbox, manage pods, or mint keys. The full `token` is returned once, here — store it securely, it cannot be retrieved again. An account-wide key, or a pod-scoped key for this inbox's pod, may mint; an inbox-scoped key cannot. # Delete inbox Source: https://docs.openmail.sh/api-reference/inboxes/delete-inbox https://api.openmail.sh/openapi.json delete /v1/inboxes/{id} Permanently delete an inbox and stop delivery to its address. This action cannot be undone. An account-wide key or a pod-scoped key for this inbox's pod may delete it; an inbox-scoped key cannot. # Get inbox Source: https://docs.openmail.sh/api-reference/inboxes/get-inbox https://api.openmail.sh/openapi.json get /v1/inboxes/{id} Retrieve a single inbox by its ID, including its address, display name, and creation time. # List inbox API keys Source: https://docs.openmail.sh/api-reference/inboxes/list-inbox-api-keys https://api.openmail.sh/openapi.json get /v1/inboxes/{id}/api-keys List the (non-revoked) API keys scoped to this inbox. Only non-secret fields are returned — the token itself is never retrievable after creation. An account-wide key, or a pod-scoped key for this inbox's pod, may list; an inbox-scoped key cannot. # List inboxes Source: https://docs.openmail.sh/api-reference/inboxes/list-inboxes https://api.openmail.sh/openapi.json get /v1/inboxes List all inboxes for your account. Supports pagination. # Revoke inbox API key Source: https://docs.openmail.sh/api-reference/inboxes/revoke-inbox-api-key https://api.openmail.sh/openapi.json delete /v1/inboxes/{id}/api-keys/{keyId} Permanently revoke an inbox-scoped API key. Any integration using it immediately starts getting 401s. An account-wide key, or a pod-scoped key for this inbox's pod, may revoke; an inbox-scoped key cannot. # Introduction Source: https://docs.openmail.sh/api-reference/introduction Complete reference for the OpenMail REST API — create and manage inboxes, custom domains, send messages, read threads, handle attachments, and configure webhooks. Welcome to the OpenMail API. Our API is organized around REST. It has predictable resource-oriented URLs, accepts JSON-encoded request bodies, and returns JSON-encoded responses. **Base URL:** `https://api.openmail.sh` Base URL, error format, and common error codes Bearer token and API key setup Create, list, get, and delete inboxes Send and receive from your own domain ## Quick reference `Authorization: Bearer om_...` Send endpoint accepts an optional `Idempotency-Key` header (UUID) that makes retries safe See [Setup](/guides/webhooks) for payload and signature verification 100 inbox creations/day; 10 sends/min and 200/day per inbox # Download raw message Source: https://docs.openmail.sh/api-reference/messages/download-raw-message https://api.openmail.sh/openapi.json get /v1/messages/{id}/raw Returns the original RFC 822 bytes of an inbound message as `message/rfc822`, exactly as received. # List messages Source: https://docs.openmail.sh/api-reference/messages/list-messages https://api.openmail.sh/openapi.json get /v1/inboxes/{id}/messages List all messages in an inbox, across every thread. Supports pagination for high-volume inboxes. # Send email Source: https://docs.openmail.sh/api-reference/messages/send-email https://api.openmail.sh/openapi.json post /v1/inboxes/{id}/send Send an email from an inbox. Optionally pass an Idempotency-Key header to make retries safe: a repeat with the same key within 24 hours returns the original message instead of sending again. Without it the API generates a key and sends once. Use threadId to reply to an existing thread. When replying on a thread where your inbox was CC'd (deliveryRole cc), the original sender is automatically CC'd. Every recipient this call can reach — `to`, `cc` (including any address auto-CC'd on a thread reply), and `replyTo` — is checked against the inbox's outbound correspondent policy before anything is sent. A single denied recipient rejects the whole send with 403 `recipient_not_permitted`; there is no partial delivery. Bcc is not supported. A request carrying a `bcc` field is rejected with 400 `unsupported_field` rather than accepted with the blind recipient dropped, so a recipient can never appear to have been addressed without being delivered to and policy-checked. # API Overview Source: https://docs.openmail.sh/api-reference/overview OpenMail REST API reference overview — base URL, JSON request/response format, HTTP status codes, and standardized error payloads explained. ## Base URL ``` https://api.openmail.sh ``` ## Request format * All request bodies are JSON (`Content-Type: application/json`) * All responses are JSON * Dates are ISO 8601 (`2026-02-24T10:00:00.000Z`) * IDs are UUIDs ## Error format All errors return a consistent structure: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "error": "error_code", "message": "Human-readable description" } ``` ## Common error codes | Status | Code | Description | | ------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_mailbox_name` | Mailbox name doesn't meet format requirements | | 400 | `unsupported_field` | The request carried a field OpenMail does not support — notably `bcc`, which is rejected rather than silently dropped. See [Sender rules](/concepts/sender-rules#bcc-is-not-supported). | | 401 | `unauthorized` | Invalid or missing API key | | 402 | `spend_cap_paused` | Monthly spend limit reached; domain creation is paused | | 403 | `feature_requires_paid_plan` | Paid-only feature used on free plan (e.g. external `replyTo`) | | 403 | `plan_limit` | Custom domains require Pro or above | | 403 | `forbidden` | A [scoped key](/api-reference/authentication#key-scopes) reached outside its pod or inbox, or attempted an action its scope doesn't allow — including any `/v1/policy` call, or deleting / re-checking a domain | | 403 | `recipient_not_permitted` | A `to`, `cc`, or `replyTo` address is not permitted by the inbox's outbound [sender rules](/concepts/sender-rules). The refused address is in `recipient`, and the whole send is rejected. | | 403 | `inherited_rule` | Attempted to delete a sender rule owned by a parent scope. Remove it at the scope that owns it. | | 404 | `not_found` | Resource doesn't exist or doesn't belong to your account | | 409 | `address_taken` | Mailbox name or address already in use | | 409 | `domain_exists` | Domain already added on this account | | 409 | `domain_taken` | Domain belongs to another account | | 409 | `identity_exists` | Domain needs manual reconnection | | 422 | `invalid_domain` | Domain is malformed, not verified, not yours, or scoped to a different pod | | 422 | `domain_blocked` | Domain flagged as unsafe | | 422 | `domain_has_inboxes` | Domain still has inboxes | | 422 | `recipient_suppressed` | Recipient is on the suppression list | | 422 | `api_key_limit_reached` | Pod or inbox already has 20 active API keys | | 429 | `rate_limit_exceeded` | Too many requests - check `Retry-After` header | | 429 | `cold_outreach_limit` | Cold send limit exceeded for new inbox | | 502 | `provider_error` | Mail provider failed to set up or check the domain | ## Idempotency The `POST /v1/inboxes/:id/send` endpoint accepts an optional `Idempotency-Key` header. Without it, OpenMail generates a key and sends once. If you retry a request with the same key, we return the original response without sending a duplicate email. Keys are scoped to your account and expire after 24 hours. # Create pod Source: https://docs.openmail.sh/api-reference/pods/create-pod https://api.openmail.sh/openapi.json post /v1/pods Create a new pod (an isolated sub-account for one of your end users or tenants). Pass your own `clientId` to map the pod to a record in your system and address it later without storing the OpenMail ID. # Create pod API key Source: https://docs.openmail.sh/api-reference/pods/create-pod-api-key https://api.openmail.sh/openapi.json post /v1/pods/{id}/api-keys Mint an API key scoped to this pod. The key can read and send from the pod's inboxes, create and delete inboxes in this pod, mint/revoke inbox keys for those inboxes, and manage pod/inbox policy and pod-scoped domains. It cannot change webhook config, manage pods, mint further pod keys, reach another pod, or address account-wide policy or domains. The full `token` is returned once, here — store it securely, it cannot be retrieved again. Requires an account-wide key (a pod-scoped key cannot mint keys). # Delete pod Source: https://docs.openmail.sh/api-reference/pods/delete-pod https://api.openmail.sh/openapi.json delete /v1/pods/{id} Permanently delete a pod. The pod must be empty (no inboxes or pod-scoped domains) and cannot be the default pod. # Get pod Source: https://docs.openmail.sh/api-reference/pods/get-pod https://api.openmail.sh/openapi.json get /v1/pods/{id} Retrieve a single pod by its OpenMail ID or your own `clientId`. # List pod API keys Source: https://docs.openmail.sh/api-reference/pods/list-pod-api-keys https://api.openmail.sh/openapi.json get /v1/pods/{id}/api-keys List the (non-revoked) API keys scoped to this pod. Only non-secret fields are returned — the token itself is never retrievable after creation. # List pods Source: https://docs.openmail.sh/api-reference/pods/list-pods https://api.openmail.sh/openapi.json get /v1/pods List all pods for your account. The default pod is always returned first. Supports pagination. # Revoke pod API key Source: https://docs.openmail.sh/api-reference/pods/revoke-pod-api-key https://api.openmail.sh/openapi.json delete /v1/pods/{id}/api-keys/{keyId} Permanently revoke a pod-scoped API key. Any integration using it immediately starts getting 401s. Requires an account-wide key. # Update pod Source: https://docs.openmail.sh/api-reference/pods/update-pod https://api.openmail.sh/openapi.json patch /v1/pods/{id} Update a pod's `clientId` or `name`. Only the fields you include are changed. # Add correspondent policy rule Source: https://docs.openmail.sh/api-reference/policy/add-correspondent-policy-rule https://api.openmail.sh/openapi.json post /v1/policy/rules Add one allow/block rule for a direction of a scope's policy. Inbox-scoped keys are rejected. A pod-scoped key may mutate its own pod or an inbox in that pod, never account-wide policy. Pod keys cannot add allows on a parent allowlist (blocks are fine). # Delete correspondent policy rule Source: https://docs.openmail.sh/api-reference/policy/delete-correspondent-policy-rule https://api.openmail.sh/openapi.json delete /v1/policy/rules/{id} Remove a single policy rule by its ID. Inbox-scoped keys are rejected. A pod-scoped key may delete a rule on its own pod or an inbox in that pod, never an account-wide rule. # Get correspondent policy Source: https://docs.openmail.sh/api-reference/policy/get-correspondent-policy https://api.openmail.sh/openapi.json get /v1/policy Read the correspondent policy for a scope (account by default, or a pod/inbox). Returns the mode this scope sets for each direction, the effective mode after inheritance, this scope's own rules, and the rules inherited from parent scopes. Inbox-scoped keys are rejected. A pod-scoped key may read its own pod or an inbox in that pod, never account-wide policy. Account-wide keys may read any scope. # List correspondent policy audit events Source: https://docs.openmail.sh/api-reference/policy/list-correspondent-policy-audit-events https://api.openmail.sh/openapi.json get /v1/policy/audit Read the append-only audit trail for correspondent policy, newest first. Two kinds of event share one stream: `rule_added`, `rule_removed`, `mode_changed`, and `scope_deleted` record who moved the boundary and how (`scope_deleted` covers a pod or inbox being deleted, which cascade-deletes its policy — the row carries the modes and rules that were removed, so lifting a restriction by deleting its scope is not a silent operation). `send_rejected` and `inbound_rejected` record what the boundary refused: a send the outbound policy would not make, and inbound mail it would not accept. Both are deduped to one row per inbox + correspondent per hour, so neither a looping agent nor an outside sender can flood the trail. Rows are never updated or deleted. Inbox-scoped keys are rejected. A pod-scoped key may read the trail for its own pod or an inbox in that pod (`?podId=` / `?inboxId=`), never the unfiltered account-wide trail. # Replace correspondent policy Source: https://docs.openmail.sh/api-reference/policy/replace-correspondent-policy https://api.openmail.sh/openapi.json put /v1/policy Declaratively set a scope's entire policy in one call — ideal for automated provisioning. Sets the inbound and/or outbound mode and REPLACES the scope's own rules for any direction you include. Rules created here are fail-closed (an empty allowlist denies all). Omitted directions are left untouched. Inbox-scoped keys are rejected. A pod-scoped key may mutate its own pod or an inbox in that pod, never account-wide policy. Pod keys cannot set `none` or extra allows on a parent allowlist. # Set correspondent policy mode Source: https://docs.openmail.sh/api-reference/policy/set-correspondent-policy-mode https://api.openmail.sh/openapi.json put /v1/policy/mode Set the filtering mode for one direction of a scope's policy, without touching rules. Inbox-scoped keys are rejected. A pod-scoped key may mutate its own pod or an inbox in that pod, never account-wide policy. Pod keys cannot set `none`. # Get thread messages Source: https://docs.openmail.sh/api-reference/threads/get-thread-messages https://api.openmail.sh/openapi.json get /v1/threads/{id}/messages Retrieve a thread and all of its messages in order, with read status and full message content. # List threads Source: https://docs.openmail.sh/api-reference/threads/list-threads https://api.openmail.sh/openapi.json get /v1/inboxes/{id}/threads List threads for an inbox. Use isRead to filter by read status — for example, ?isRead=false returns only unread threads. (snake_case is_read is accepted as an alias.) # Update thread Source: https://docs.openmail.sh/api-reference/threads/update-thread https://api.openmail.sh/openapi.json patch /v1/threads/{id} Update a thread's read status. Use this to mark a thread as read after your agent has processed it, or as unread to re-queue it for later processing. # Agent keys Source: https://docs.openmail.sh/best-practices/api-key-scopes Give each agent an inbox-scoped API key so a compromised or prompt-injected agent can reach nothing but its own inbox. Keep the account-wide key on your side. The key you get at signup reaches every inbox on your account. An agent runs untrusted input all day, so never hand it that key. Give it the narrowest scope that fits and keep the account-wide key on your side. ## Pick the scope | Agent owns | Use | Mint with | | -------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- | | One inbox | [Inbox-scoped key](/concepts/inboxes#inbox-scoped-api-keys) | `POST /v1/inboxes/{id}/api-keys` | | Several inboxes (a tenant) | [Pod-scoped key](/concepts/pods#pod-scoped-api-keys) | `POST /v1/pods/{id}/api-keys` | | Everything | Account-wide key | [Console](https://console.openmail.sh/api-keys) only. Keep it server-side. | An inbox-scoped key can read and send from its one inbox and nothing else. It cannot see other inboxes, create or delete inboxes, mint keys, or read the [sender rules](/concepts/sender-rules) that apply to it. If an email tricks the agent, the damage stops at that inbox. ## Mint one Minting needs an account-wide key, or a pod key for the inbox's pod. Run it from your own shell or backend, never from the agent's. The token is shown once. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail inbox keys create --inbox-id --name my-agent --json ``` Put the returned `token` in the agent's `OPENMAIL_API_KEY`. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X POST https://api.openmail.sh/v1/inboxes//api-keys \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "my-agent"}' ``` Write the returned `token` into the agent's environment as `OPENMAIL_API_KEY`. The [API integration guide](/guides/api-integration#step-1-create-an-inbox-and-its-key) does this right after creating the inbox. ## Plugins do this for you Both plugins swap an account key for a **pod-scoped** key during setup and store only that. The account key never touches disk. A pod key still lets the agent create inboxes in its pod, which is what lets one account grow into several inboxes later without a new key from the console. * **OpenClaw**: `channels add` * **Hermes Agent**: `hermes openmail setup` Pass an inbox-scoped key instead if you want the agent confined to one inbox; both plugins store it as-is. ## Rotate and revoke Each inbox and pod holds up to 20 active keys. To rotate, mint a new key, switch the agent over, then revoke the old one; it gets `401` immediately. Deleting an inbox revokes its keys with it. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail inbox keys list --inbox-id openmail inbox keys revoke --inbox-id --key-id ``` ## Related * [Key scopes](/api-reference/authentication#key-scopes): the full comparison. * [Sender rules](/concepts/sender-rules): limit who an inbox can email, out of the agent's reach. # Email deliverability Source: https://docs.openmail.sh/best-practices/email-deliverability Best practices for landing outbound emails in the inbox. Covers IP warming, SPF/DKIM/DMARC alignment, content tips, and suppression handling. Deliverability is whether your email lands in the inbox or the spam folder. Providers like Gmail and Outlook decide that from your sending reputation, and reputation is earned; how you send matters as much as what you send. This guide covers both. ### SPF, DKIM, and DMARC Set these up before your first send. They are DNS records that prove to receiving providers you are who you say you are. OpenMail handles SPF, DKIM, and DMARC automatically for `omail.sh` — great for getting started. For high-volume cold outreach, verify your own custom domain (Pro plan and above). See [Custom domains](/concepts/custom-domains). All inboxes use `omail.sh` by default. For production outreach at scale, send from a verified custom domain like `outreach@yourcompany.com` so your reputation belongs to you. Learn about inbox address formats and how to configure your sending domain. ## High-volume sending strategy How you send your emails is just as important as what you send. If you're sending a large volume of emails, follow these steps to build and maintain a strong sender reputation. Don't go from zero to a thousand emails overnight. Email providers get suspicious of new inboxes that immediately send a high volume. Start slow and gradually increase your sending volume over several days or weeks. This "warm-up" process signals that you're a legitimate sender. **Example warm-up schedule:** * Day 1: 10 emails/inbox * Day 2: 20 emails/inbox * Day 3: 40 emails/inbox * ...and so on, doubling until you reach your target. See [Rate limits](/concepts/rate-limits) for per-inbox sending limits. Instead of sending 10,000 emails from a single inbox, send 100 emails from 100 different inboxes. This distributes your sending volume, reduces the risk of any single inbox getting flagged, and looks much more natural to email providers. OpenMail's ability to create inboxes at scale makes this easy to implement. **Distribute on your domain.** Use multiple inboxes on a verified custom domain — one per agent, campaign, or [pod](/concepts/pods) tenant. Do not run high-volume cold outreach from shared `@omail.sh` addresses. **Reputation, not quota.** Inbox distribution protects per-inbox reputation and helps you stay under per-inbox daily limits. Your [account cold cap](/concepts/rate-limits) is shared across all inboxes — adding more inboxes does not multiply total cold volume. Upgrade your plan if you need a higher ceiling. A domain's reputation is slow to build and quick to burn. If you use a custom domain for outreach, consider rotating sending identities so a reputation hit on one doesn't impact the rest. ## High-impact content strategy Content decides whether a spam filter reads your email as a message or as junk. Address your recipients by name in the subject line and email body. Use other data points you have to make the email feel like a one-to-one conversation, not a mass blast. Generic emails are a major red flag for spam filters. Avoid "spammy" keywords (e.g., "free," "buy now," "limited time offer"), excessive exclamation points, and ALL CAPS. Write in a natural, conversational tone. The goal is to start a conversation, not to close a sale in the first email. Images embedded in the email body set off spam filters. Open-tracking pixels are just tiny images encoded into the body — email providers know it. Drop them to improve deliverability. Email providers are wary of links, especially in the first message of a conversation. Send your initial outreach with no links or images. Wait for the recipient to reply, and *then* send your call-to-action link. This looks like a natural conversation to providers. Email providers often flag HTML-only emails as spam. Including a plain text alternative demonstrates legitimacy and increases your chances of reaching the inbox. ## Suppressions OpenMail automatically maintains a suppression list for bounces and spam complaints. Recipients who bounce or mark your email as spam are suppressed from future sends. Learn how suppression lists work and how to manage them. ## What's next Per-inbox send limits and cold outreach throttling. Safe retries so agents never send duplicate emails. Address formats, external IDs, and multi-tenant routing. Full endpoint documentation. # Idempotency Source: https://docs.openmail.sh/best-practices/idempotency Use idempotency keys to safely retry failed send requests without creating duplicate emails. OpenMail rejects duplicate requests for 24 hours. The `POST /v1/inboxes/{id}/send` endpoint accepts an optional `Idempotency-Key` header. If you retry with the same key, we return the original response without sending a duplicate email. The header is optional: without it, OpenMail generates a key and sends once. Pass your own key whenever your code retries on timeouts — a retry without one is a second email. ## Usage ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X POST https://api.openmail.sh/v1/inboxes/inb_xxx/send \ -H "Authorization: Bearer om_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -d '{"to": "user@example.com", "subject": "Hello", "body": "Hi"}' ``` ## Rules * **Optional, but pass one if you retry** - Without a key, the API cannot tell a retry from a new send. * **Unique per send** - Generate a new UUID for each distinct send (e.g. `uuidgen`, `crypto.randomUUID()`). * **Scoped to account** - Keys are per-account, not global. * **24-hour expiry** - Keys expire after 24 hours. Reusing an expired key creates a new send. * **Same body** - Retries must use the same request body. Changing the body with the same key returns an error. ## Webhook idempotency For webhooks, use `event_id` to deduplicate. We may deliver the same event more than once; your handler should be idempotent. # Attachments Source: https://docs.openmail.sh/concepts/attachments Send files with outbound emails and access attachments on inbound messages. OpenMail parses and stores attachments, making them available via API. OpenMail supports attachments in both directions — send files with outbound emails and access files from inbound emails with automatic text extraction. ## Sending attachments To send an email with attachments, use `multipart/form-data` instead of JSON: ```bash curl theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X POST "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/send" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "to=recipient@example.com" \ -F "subject=Monthly report" \ -F "body=See the attached report." \ -F "attachments=@report.pdf" \ -F "attachments=@chart.png" ``` ```bash CLI theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail send --to recipient@example.com --subject "Monthly report" \ --body "See the attached report." \ --attach report.pdf --attach chart.png ``` * Maximum 25 MB total per email (provider limit) * Multiple files: repeat the `attachments` field (curl) or `--attach` flag (CLI) * JSON requests without attachments continue to work as before ## Receiving attachments When an inbound email includes attachments, OpenMail stores them, extracts readable text, and provides download URLs you fetch with your API key. ### How it works 1. Email arrives with attachments 2. We upload each file to secure storage 3. Text is extracted automatically based on file type 4. Attachment metadata — including extracted text — is included in the webhook payload and message responses ## Text extraction OpenMail automatically extracts readable text from common attachment types so your agent can read the content directly from the webhook payload or API response, without downloading and parsing files. | File type | Examples | | ----------- | -------------------------------------------------------- | | PDF | Invoices, receipts, contracts | | Images | Scanned documents, screenshots | | Office docs | `.docx`, `.xlsx`, `.pptx`, `.odt` | | CSV / TSV | Spreadsheets, data exports | | Plain text | `.txt`, `.md`, `.json`, `.html`, `.xml`, `.yaml`, `.log` | Each attachment includes a `parsedText` field when extraction succeeds: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "filename": "invoice.pdf", "contentType": "application/pdf", "sizeBytes": 45000, "url": "https://api.openmail.sh/v1/attachments/msg_.../invoice.pdf", "parsedText": "Invoice #2847\nDate: March 15, 2026\nAmount: $1,250.00\n...", "extractionMethod": "pdf" } ``` The `extractionMethod` field tells you how the text was extracted: `pdf`, `ocr`, `office`, `csv`, or `text`. If extraction failed or the file type is unsupported, `parsedText` is omitted and `extractionMethod` indicates the reason (e.g. `unsupported`, `pdf_error`, `skipped_too_large`). ### Limits * Files larger than 10 MB are skipped for extraction * Extracted text is capped at 50,000 characters per attachment * Unsupported binary formats (video, audio, archives) return `extractionMethod: "unsupported"` ## Downloading attachments Each attachment includes a `url` field in the message response: ``` https://api.openmail.sh/v1/attachments/{messageId}/{filename} ``` This endpoint returns the attachment bytes directly. Authenticate with the same `Authorization: Bearer ` header you use for the rest of the API — it is not a redirect, so no separate signed URL or expiry to worry about. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl https://api.openmail.sh/v1/attachments/msg_4c8d5e6f/receipt.pdf \ -H "Authorization: Bearer om_..." \ -o receipt.pdf ``` ## On-demand text extraction For attachments that were stored before text extraction was available, or to re-extract text, use the `/text` endpoint: ``` GET /v1/attachments/{messageId}/{filename}/text ``` ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl https://api.openmail.sh/v1/attachments/msg_4c8d5e6f/receipt.pdf/text \ -H "Authorization: Bearer om_..." ``` Returns: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "filename": "receipt.pdf", "contentType": "application/pdf", "extractionMethod": "pdf", "text": "Invoice #2847\nDate: March 15, 2026\n..." } ``` ## Size limits * Maximum attachment size is determined by the email provider (typically 25 MB per email) # Custom domains Source: https://docs.openmail.sh/concepts/custom-domains Send and receive email from your own domain with OpenMail. Learn how to configure DNS records, verify ownership, and enable custom domain routing. By default, inboxes use `@omail.sh` addresses. With a custom domain you can use any domain you own — for example `agent@yourdomain.com`. ## How it works 1. Add your domain in the dashboard or via the API 2. OpenMail returns the DNS records you need to set 3. Publish them. The domain verifies automatically once DNS is live 4. Create inboxes on it by passing the domain when you create an inbox ## Adding a domain Go to **Settings → Domains** in the dashboard and click **Add domain**. Enter the domain you want to use (e.g. `mail.acme.com`). You can also add it with `POST /v1/domains`. You can use a subdomain like `mail.acme.com` or an apex domain like `acme.com`. A subdomain is generally easier to configure without affecting other email on your domain. After adding your domain, OpenMail returns the DNS records to set at your registrar. ## DNS records | Type | Host | Value | | ----- | ------------------------------------- | ------------------------------------------------------ | | MX | `@` (or your subdomain) | `inbound-smtp.eu-north-1.amazonaws.com` (priority 10) | | CNAME | `omail._domainkey` (+ your subdomain) | Provided by OpenMail (three records) | | MX | `bounce` (+ your subdomain) | `feedback-smtp.eu-north-1.amazonses.com` (priority 10) | | TXT | `bounce` (+ your subdomain) | `v=spf1 include:amazonses.com ~all` | | TXT | `_dmarc` (+ your subdomain) | `v=DMARC1; p=none;` | Always prefer the exact records shown in the dashboard or API after you add the domain — those values are authoritative. **MX record** on your domain routes inbound email to OpenMail. **DKIM records** are three CNAMEs under `_domainkey` (hosts like `omail._domainkey`). They add a cryptographic signature to outbound email, proving messages were sent by you. The hosts and targets are unique per domain and provided after you add it. **MAIL FROM records** (`bounce` MX + TXT) set the envelope sender on your domain so SPF can align. Publish both on the `bounce` host exactly as shown — do not put the SPF include on your apex unless the records tell you to. Merge the SPF include into any `v=spf1` record you already have; two SPF records on the same host are invalid. **DMARC record** tells receivers what to do with mail that fails SPF or DKIM. Google and Yahoo require bulk senders to publish one. The default `p=none;` satisfies the requirement without affecting delivery; tighten to `quarantine` or `reject` once SPF and DKIM are aligned. If your domain already has a `_dmarc` record, keep it rather than replacing it. See [SPF, DKIM, DMARC](/pages/resources/security/email-protocols). DNS changes typically propagate within minutes but can take up to 48 hours. ## Verifying your domain You don't need to click **Verify**. Once the records are published, OpenMail checks them in the background and the domain becomes **Verified** within a couple of minutes. Click **Verify** only if you want to check sooner. DMARC is your own published policy, so it is not checked during verification — a domain verifies without it. The record is shown because bulk-sender requirements expect one. If verification fails, double-check each record at your registrar and try again. Common issues: * Existing MX records on the domain that weren't replaced * MAIL FROM MX or SPF published on the apex instead of `bounce` * Only one of the three DKIM CNAMEs published, or a host copied without the subdomain suffix * Two `v=spf1` TXT records on the same host (only one is allowed — merge includes into a single record) ## Using your domain Once a domain shows as **Verified**, it is ready to use — there is no separate activation step. Verifying the domain is what makes it available. Your account default domain never switches automatically. To create an inbox on your custom domain, choose it explicitly: ```bash API theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X POST https://api.openmail.sh/v1/inboxes \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mailboxName": "support", "domain": "yourdomain.com" }' ``` ```text Dashboard theme={"theme":{"light":"github-light","dark":"dark-plus"}} Inboxes → Create inbox → pick yourdomain.com in the domain selector ``` The resulting address uses your domain: ``` {mailboxName}@yourdomain.com ``` If you omit `domain`, the inbox is created on your account default domain (`@omail.sh`). Passing a domain that is not verified, does not belong to your account, or is scoped to a different pod returns `invalid_domain`. ## Pod scoping Custom domains can be shared across your account or limited to a single [pod](/concepts/pods): | Scope | Meaning | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **All pods** | Account-wide. Any pod can create inboxes on this domain. **New domains default here** — unlike inboxes, they are not placed in the default pod unless you explicitly scope them. | | **One pod** | Only inboxes in that pod may use the domain. The domain lives under that pod in your account hierarchy. | When adding a domain, pick **All pods** or a specific pod. On a pod's detail page, the **Domains** tab lists domains scoped to that pod; account-wide domains still appear when creating inboxes inside the pod. To move a scoped domain to another pod (or make it account-wide), reassign it in the dashboard. Narrowing scope is blocked while inboxes on that domain live outside the target pod. ## Limits Custom domains are available on the Pro plan and above. | Plan | Custom domains | | ---------- | -------------- | | Free | — | | Pro | Unlimited | | Enterprise | Unlimited | ## Removing a domain You can delete a custom domain from the dashboard or API as long as it has no inboxes. Delete the inboxes on that domain first, then remove the domain. Deleting a domain removes it from OpenMail but does not delete your DNS records — you can remove those from your registrar separately. # Inboxes Source: https://docs.openmail.sh/concepts/inboxes Every OpenMail agent gets a dedicated email address and inbox. Learn how to create, configure, and manage inboxes via API or CLI. An inbox is a unique email address assigned to an agent. When you create an inbox, the agent can immediately send and receive email from that address. ## Address format Inboxes use the `omail.sh` domain: ``` {mailboxName}@omail.sh ``` For example: `support@omail.sh` * If you provide a `mailboxName`, it becomes the local part of the address * If you omit it, we generate a random 8-character alphanumeric string * Mailbox names must be 3–30 characters, lowercase alphanumeric with dots and hyphens allowed Need your own domain (e.g. `agent@yourdomain.com`)? See [Custom domains](/concepts/custom-domains). ## Sender name Set `displayName` when creating or updating an inbox to control how recipients see the sender. Without a display name, recipients see the raw email address. With a display name, the From header becomes `Name
`. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "displayName": "John Smith", "mailboxName": "john" } ``` Recipients will see **John Smith** instead of the raw email address. You can update the display name at any time with `PATCH /v1/inboxes/:id`. ## Multi-tenant routing Route inbound events by `inbox_id`. Map each inbox ID to your user/agent/container in your app. **Multi-tenant** - One account, many inboxes. Create one inbox per agent and route by `inbox_id`. To confine an agent to the single inbox it owns, give it an [inbox-scoped API key](#inbox-scoped-api-keys). To isolate a tenant that owns several inboxes, see the [Multi-tenancy guide](/guides/multi-tenancy). **Multiple inboxes per user** - You can create multiple inboxes for the same user. Webhooks include `inbox_id` so you can identify which inbox received the message. See [Email deliverability](/best-practices/email-deliverability) for warm-up schedules and content best practices. ## Inbox-scoped API keys Your account-wide key reaches every inbox. When one agent owns exactly one inbox, hand it an **inbox-scoped key** instead — the narrowest scope OpenMail offers: * It can **read and send** from that one inbox, including its threads, messages, and drafts. Every other inbox is invisible to it — even listing inboxes returns only the one it owns. * It is **read and send only**. It cannot create, modify, or delete inboxes, and cannot rotate a webhook secret. * It **cannot mint or revoke keys**, so a leaked key cannot widen its own reach. * It **cannot read or change [sender rules](/concepts/sender-rules)**. Every `/v1/policy` call from a scoped key returns `403`, so who the inbox can email is set by you, not the agent. Anything outside that boundary is refused rather than silently ignored — see [`forbidden`](/api-reference/overview#common-error-codes) if you need to handle it. Minting requires an **account-wide** key, or a pod-scoped key for the inbox's pod — an inbox-scoped key cannot mint. The full `token` is returned once, at creation, and cannot be retrieved again. Listing returns only a masked `tokenPrefix` and `last4`. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # Mint a key confined to a single inbox curl -X POST https://api.openmail.sh/v1/inboxes/INBOX_ID/api-keys \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "support-agent"}' # 201 -> { "id": "...", "token": "om_...", "inboxId": "...", "podId": "...", "name": "...", "last4": "..." } # List the inbox's keys (masked — no token) curl "https://api.openmail.sh/v1/inboxes/INBOX_ID/api-keys" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # Revoke a key by its ID curl -X DELETE https://api.openmail.sh/v1/inboxes/INBOX_ID/api-keys/KEY_ID \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` Each inbox allows up to 20 active keys; revoke an unused one to free a slot. Revoking takes effect immediately, on the key's very next request. Deleting an inbox also revokes every key scoped to it. On [WebSockets](/concepts/websockets), a connection authenticated with an inbox-scoped key streams only that inbox's events, so a subscribe-all needs no filtering on your side. Choosing between scopes? See [Key scopes](/api-reference/authentication#key-scopes) for the full comparison. ## Limits | Plan | Inboxes | | ---------- | -------------------------------------------------------- | | Free | 3 (hard cap) | | Pro | 10 included, then €1/inbox/month — no cap, never blocked | | Enterprise | Custom | ## Lifecycle Deleting an inbox (`DELETE /v1/inboxes/:id`) permanently removes the inbox and all associated messages, threads, and attachments. This is not reversible. # Pods Source: https://docs.openmail.sh/concepts/pods Pods are isolated sub-accounts inside your OpenMail account. Use them to keep each of your end users or tenants separate while billing stays on one account. A pod is an isolated workspace inside your account. Each pod holds its own inboxes, so you can give every one of your end users (or tenants, agents, or projects) a clean, separate slice of OpenMail while keeping a single account and one bill. A [pod-scoped API key](#pod-scoped-api-keys) locks an integration to a single pod. Pods are designed for multi-tenant apps. If you run one agent for yourself, you don't need to think about them — your inboxes simply live in your account's default pod. ## The hierarchy ``` Account (your OpenMail account, account-wide API key) ├── Domain (account-wide) ← usable by every pod; optional ├── Default pod ← inboxes land here unless you say otherwise │ ├── Inbox │ └── Inbox ├── Pod (your customer A) │ ├── Domain (scoped) ← only this pod can use it │ ├── Inbox │ └── Inbox └── Pod (your customer B) ├── Domain (scoped) ├── Inbox └── Inbox ``` * **Account** — Your business. One account, one set of API keys, one bill. * **Pod** — A tenant in your product (a customer, end user, agent, or environment). Provides organizational isolation between sets of inboxes. * **Domain** — A verified custom domain. Either **account-wide** (lives at account level, any pod can use it) or **scoped to one pod** (lives under that pod). A domain cannot be scoped to more than one pod, but not all pods. * **Inbox** — An email address. Always belongs to exactly one pod. ## When to use pods * **Multi-tenant SaaS / agencies** — Create one pod per customer account so their inboxes never mix with another customer's. * **White-label email** — Give each end user their own pod and keep their data cleanly separated under your brand. * **AI agent platforms** — Give each agent (support, sales, marketing) its own pod with dedicated inboxes. * **Environments or teams** — Separate staging from production, or teams from each other, under one account. If you just need many inboxes under one umbrella and don't require isolation, you can skip pods and create inboxes directly — they land in the default pod. ## Common patterns The same building block — one pod per tenant — maps onto a few common shapes. **Multi-tenant SaaS** — one pod per customer company, addressed by your own tenant ID: ``` Account ├── Pod clientId: "meridian" (Meridian Analytics) │ ├── Domain: meridian.io │ ├── support@meridian.io │ └── billing@meridian.io └── Pod clientId: "cedar" (Cedar Works) ├── Domain: cedarworks.com ├── hello@cedarworks.com └── team@cedarworks.com ``` **Agency / white-label** — one pod per client you manage on their behalf: ``` Account ├── Pod clientId: "client-vantage" (Vantage Retail) │ ├── Domain: vantageretail.com │ └── info@vantageretail.com └── Pod clientId: "client-slate" (Slate Capital) ├── Domain: slatecapital.ai └── support@slatecapital.ai ``` **AI agent platform** — one pod per agent, each with its own set of inboxes: ``` Account ├── Domain: relaystack.com ← account-wide; shared across agent pods ├── Pod "support-agent" │ ├── support@relaystack.com │ ├── help@relaystack.com │ └── tickets@relaystack.com └── Pod "sales-agent" ├── sales@relaystack.com ├── outreach@relaystack.com └── leads@relaystack.com ``` Each tenant pod typically owns its own scoped domain. When several pods share one domain (like the agent platform above), keep it **account-wide** so every pod can use it. ## The default pod Every account has exactly one **default pod**, created automatically when you sign up. * Any inbox you create without specifying a pod lands in the default pod. * The default pod is always listed first and is marked with `"isDefault": true`. * It cannot be deleted. ## clientId When you create a pod you can pass your own `clientId` — a stable identifier from your own system, such as your user or tenant ID. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "clientId": "meridian", "name": "Meridian Analytics" } ``` The `clientId` must be unique within your account. Once set, you can address a pod by it anywhere the API takes a pod identifier — without ever storing OpenMail's pod ID: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # Get a pod by your own identifier curl https://api.openmail.sh/v1/pods/meridian \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # Create an inbox directly in that pod curl -X POST https://api.openmail.sh/v1/inboxes \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"podId": "meridian"}' ``` Pass your own user or tenant ID as `clientId` at signup. Then you can create and look up that tenant's inboxes using your ID, with no extra mapping table on your side. Both `clientId` and `name` are optional. Omit `clientId` and OpenMail still generates a pod with its own ID. ## Scoping inboxes to a pod Pods and inboxes connect through the inbox's `podId`: * **Create** — Pass `podId` (a pod ID or `clientId`) to `POST /v1/inboxes` to place the inbox in that pod. Omit it to use the default pod. * **List** — Pass `podId` to `GET /v1/inboxes` to return only that pod's inboxes. * **Read** — Every inbox response includes its `podId`. An inbox always belongs to exactly one pod. Inboxes cannot be moved between pods after creation — pick the pod when you create the inbox. ## Custom domains and pods [Custom domains](/concepts/custom-domains) sit in one of two places in the hierarchy: | Scope | Where it lives | Who can use it | | ---------------- | ------------------------------------ | ------------------------ | | **Account-wide** | Under the account (not inside a pod) | Any pod | | **Pod-scoped** | Under that pod | Only inboxes in that pod | When you create an inbox, `domain` and `podId` must agree: pod-scoped domains require the inbox in that same pod; account-wide domains work in any pod. You cannot scope a domain to several pods but not all — it is either one pod or every pod. Scope a domain when you add it — pick **All pods** or a specific pod in the dashboard, or pass the pod when you create it via the API. To move a scoped domain later, reassign it in the dashboard (**Settings → Domains**, or a pod's **Domains** tab). See [Pod scoping](/concepts/custom-domains#pod-scoping). ## What pods do and don't isolate Pods give you **organizational** isolation — a clean way to group and scope inboxes per tenant. They are not a network boundary: * **Email is not walled off.** Inboxes in different pods are still ordinary email addresses; they can send to and receive from each other and the outside world. * **The account-wide key reaches everything.** The key you get at signup can read and write resources in *every* pod, so never hand it to an end user. Mint a [pod-scoped key](#pod-scoped-api-keys) instead — it can only reach its own pod. If the recipient owns just one inbox, an [inbox-scoped key](/concepts/inboxes#inbox-scoped-api-keys) narrows it further still. ## Pod-scoped API keys Your account-wide key reaches every pod. To limit an integration — a tenant, an agent, an environment — to a single pod, mint a **pod-scoped key**: * It can **read and send** only from that pod's inboxes, create inboxes in it, and add a [custom domain](/concepts/custom-domains) scoped to that pod. Everything in another pod is out of reach. * It **cannot delete an inbox**, change an inbox's webhook config, or rotate a webhook secret — those need an account-wide key, so a leaked pod key cannot destroy mail or redirect it elsewhere. * It **cannot manage pods** and cannot mint or revoke keys. * It **cannot read or change [sender rules](/concepts/sender-rules)** at any scope — every `/v1/policy` call returns `403`. * Revoking it takes effect immediately, on the key's very next request. Minting requires an **account-wide** key. The full `token` is returned once, at creation, and cannot be retrieved again — store it securely. Listing returns only a masked `tokenPrefix` and `last4` so you can identify a key without exposing it. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # Mint a key scoped to the Meridian pod (addressed by your own clientId) curl -X POST https://api.openmail.sh/v1/pods/meridian/api-keys \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "meridian-support-agent"}' # 201 -> { "id": "...", "token": "om_...", "podId": "...", "name": "...", "last4": "..." } # List the pod's keys (masked — no token) curl "https://api.openmail.sh/v1/pods/meridian/api-keys" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # Revoke a key by its ID curl -X DELETE https://api.openmail.sh/v1/pods/meridian/api-keys/KEY_ID \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` Each pod allows up to 20 active keys; revoke an unused one to free a slot. You can also mint and revoke keys from a pod's page in the console (account owners only). A pod-scoped key is the right scope for a tenant that owns several inboxes. For a single agent that owns exactly one, an [inbox-scoped key](/concepts/inboxes#inbox-scoped-api-keys) confines it to that inbox alone. See [Key scopes](/api-reference/authentication#key-scopes) for the full comparison. See the [Multi-tenancy guide](/guides/multi-tenancy) for the full tenant onboarding flow: pod, inboxes, and scoped key end to end. ## Lifecycle You can delete a pod with `DELETE /v1/pods/:id`, with these guardrails: * The pod must be **empty** — delete its inboxes first, or you'll get `pod_not_empty` (409). * The pod must not **own pod-scoped custom domains** — reassign them to account-wide or another pod first, or you'll get `pod_has_domains` (409). * The **default pod** cannot be deleted (`default_pod`, 409). Deleting a pod does not delete inboxes or domains; it only removes an empty container. To offboard a customer, clean up their pod-scoped resources first (you can address the pod by your own `clientId`): ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # 1. List the pod's inboxes curl "https://api.openmail.sh/v1/inboxes?podId=meridian" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # 2. Delete each inbox (deleting an inbox also removes its messages and threads) curl -X DELETE https://api.openmail.sh/v1/inboxes/INBOX_ID \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # 3. Reassign or delete any pod-scoped domains (dashboard, or delete the # domain once it has no inboxes) # 4. Now the pod is empty and can be deleted curl -X DELETE https://api.openmail.sh/v1/pods/meridian \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` ## Managing pods in the console You can also manage pods without code. In the console, open **Pods** to see every pod, create new ones, and open a pod to view its inboxes and pod-scoped domains. The default pod is labeled with a **Default** badge. ## Give your agent the pods API Paste this into Cursor, Claude, or any coding agent to give it the full pods API in one shot: ```text theme={"theme":{"light":"github-light","dark":"dark-plus"}} OpenMail Pods — multi-tenant isolation. REST API, bearer auth with an account API key. Base URL: https://api.openmail.sh Auth header: Authorization: Bearer A pod is an isolated container for inboxes (one per tenant/customer/agent). Every account has one auto-created default pod; inboxes with no pod land there. Address any pod by its id OR by your own clientId. Pods: - GET /v1/pods list pods (default pod first); ?limit&offset - POST /v1/pods body: { clientId?, name? } -> 201 pod - GET /v1/pods/{idOrClientId} fetch one pod - PATCH /v1/pods/{idOrClientId} body: { clientId?, name? } - DELETE /v1/pods/{idOrClientId} must be empty and not the default pod 409 pod_not_empty -> delete its inboxes first 409 pod_has_domains -> reassign pod-scoped domains first 409 default_pod -> the default pod cannot be deleted Inboxes (scoped to a pod): - POST /v1/inboxes body: { podId?, mailboxName?, displayName?, domain? } podId accepts a pod id or clientId; omit to use the default pod 400 invalid_pod -> podId did not match a pod in your account - GET /v1/inboxes?podId= list only that pod's inboxes - every inbox object includes its podId Custom domains: POST /v1/domains { domain, podId? }. Omit podId for account-wide. Verifies automatically after you publish DNS. Delete once it has no inboxes. Pod-scoped API keys (mint requires an account-wide key): - POST /v1/pods/{idOrClientId}/api-keys body: { name? } -> 201 { token, ... } token returned once; scoped to this pod (read/send only, 403 cross-pod, no pod mgmt) 422 api_key_limit_reached -> revoke an unused key first - GET /v1/pods/{idOrClientId}/api-keys list keys (masked: tokenPrefix + last4) - DELETE /v1/pods/{idOrClientId}/api-keys/{keyId} revoke -> key gets 401 immediately Inbox-scoped API keys (mint requires an account-wide key, or a pod key for the inbox's pod): - POST /v1/inboxes/{id}/api-keys body: { name? } -> 201 { token, ... } token returned once; confined to this one inbox (read/send only, 403 on any other inbox, cannot create/modify/delete inboxes, no key mgmt) 422 api_key_limit_reached -> revoke an unused key first - GET /v1/inboxes/{id}/api-keys list keys (masked: tokenPrefix + last4) - DELETE /v1/inboxes/{id}/api-keys/{keyId} revoke -> key gets 401 immediately - deleting the inbox revokes its keys too Notes: - Inboxes can't move between pods; choose the pod at creation time. - Three key scopes: account-wide reaches everything, pod-scoped reaches one pod, inbox-scoped reaches one inbox. Pod keys are minted by the account key; inbox keys by the account key or the pod's key. A key never widens its own reach. - Max 20 active keys per pod and per inbox. - Pods are organizational isolation, not network isolation. ``` ## Frequently asked questions No. Pods are optional. Every account has a default pod, so if you only manage email for yourself you can create inboxes directly and ignore pods entirely. Reach for pods when you need to isolate inboxes per customer or tenant. Yes. Pods provide organizational isolation, not network isolation. Inboxes in different pods are ordinary email addresses and can send to and receive from one another like any other mailbox. No. An inbox's pod is fixed at creation. To "move" an inbox, create a new one in the target pod and delete the old one. There's no enforced limit on the number of pods. Note that inboxes are still subject to your plan's inbox limits across the whole account — see [Inboxes](/concepts/inboxes#limits). Yes. Mint a **pod-scoped key** with `POST /v1/pods/{id}/api-keys` or from the pod's page in the console. It can only read and send from that pod's inboxes, create inboxes in it, and add a custom domain scoped to it. Everything else is out of reach. The token is shown once at creation, and you can revoke the key at any time. See [Pod-scoped API keys](#pod-scoped-api-keys). Yes. Mint an **inbox-scoped key** with `POST /v1/inboxes/{id}/api-keys` — the narrowest scope available. It can read and send from that one inbox and nothing else, and unlike a pod-scoped key it cannot create or delete inboxes at all. Useful when one agent owns exactly one inbox. See [Inbox-scoped API keys](/concepts/inboxes#inbox-scoped-api-keys). Yes. Scope a domain to one pod when you add it and create inboxes on it only inside that pod — for example `support@meridian.io` in the Meridian Analytics pod and `hello@cedarworks.com` in the Cedar Works pod. You can also add account-wide domains that every pod shares. See [Custom domains](/concepts/custom-domains). ## API reference See the [API reference](/api-reference/introduction) for full request and response details. Pod endpoints: | Operation | Endpoint | | ------------------------- | --------------------------------------- | | List pods | `GET /v1/pods` | | Create pod | `POST /v1/pods` | | Get pod | `GET /v1/pods/{id}` | | Update pod | `PATCH /v1/pods/{id}` | | Delete pod | `DELETE /v1/pods/{id}` | | Mint pod-scoped API key | `POST /v1/pods/{id}/api-keys` | | List pod-scoped API keys | `GET /v1/pods/{id}/api-keys` | | Revoke pod-scoped API key | `DELETE /v1/pods/{id}/api-keys/{keyId}` | # Rate Limits Source: https://docs.openmail.sh/concepts/rate-limits OpenMail enforces per-inbox send limits and cold-outreach throttling to protect deliverability. Learn how limits work and how to stay within them. OpenMail enforces rate limits to protect domain reputation and ensure deliverability for all customers. ## Send limits | Limit | Value | Scope | | -------------- | --------- | ----------- | | Burst | 10/minute | Per inbox | | Daily | 200/day | Per inbox | | Inbox creation | 100/day | Per account | ## Cold outreach throttle A "cold" send is an email to a recipient who has never sent an inbound message to this inbox. Cold sends have two independent caps, both reset per UTC day. A send only goes through if it is under **both**. ### Per-inbox cap | Inbox age | Cold send limit | | ------------- | ------------------------------ | | First 30 days | 20/day per inbox | | After 30 days | Standard daily limit (200/day) | ### Per-account cap A separate cap applies across all of your inboxes combined, so adding more inboxes does not multiply your cold quota. It scales with your plan and loosens once your account is more than 30 days old: | Plan | First 30 days | After 30 days | | ---- | ------------- | ------------- | | Free | 30/day | 100/day | | Pro | 150/day | 500/day | Enterprise plans are exempt from the per-account cap. These limits protect your domain reputation while it warms up. Relationship email (replies to recipients who have emailed you) is always "warm" and bypasses both caps. ## Rate limit responses When rate limited, the API returns `429` with a `Retry-After` header: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "error": "rate_limit_exceeded", "message": "Max 10 sends per minute per inbox" } ``` ## Why this matters Fresh domains on shared IP pools need time to build reputation. If your agents send too much cold outreach too quickly, emails will land in spam - which hurts your agents' effectiveness and your domain's reputation. These limits exist to protect deliverability. Contact us if you need higher limits or a dedicated sending IP. For a complete warm-up strategy and content best practices, see [Email deliverability](/best-practices/email-deliverability). # Sender rules Source: https://docs.openmail.sh/concepts/sender-rules Control who your inboxes may receive from and who they may send to. OpenMail enforces scoped allow and block rules on inbound delivery and every outbound send. Sender rules control who an inbox may receive from and who it may send to. OpenMail evaluates them server-side, before delivery and before a send goes out. An agent cannot bypass them: a [scoped key](/api-reference/authentication#key-scopes) cannot read or change policy at all. ## The model A policy has two halves: **inbound** (who may write to you) and **outbound** (who you may write to). Each half is set independently. Policy lives at three scopes: | Scope | Applies to | | -------------------------- | ----------------------- | | Account | Every inbox you own | | [Pod](/concepts/pods) | Every inbox in that pod | | [Inbox](/concepts/inboxes) | One inbox | Each direction at each scope has a **mode**: | Mode | Behavior | | ----------- | ----------------------------------------------------------------------------- | | `none` | No filtering. Block rules still apply. | | `allowlist` | Only listed senders or recipients are permitted. | | `inherit` | Defer to the parent scope. Not valid at the account scope, which is the root. | ### Inheritance The two halves inherit differently: * **Modes override.** Resolution walks inbox → pod → account and stops at the first scope with a mode other than `inherit`. If nothing is set anywhere, the mode is `none`. * **Rules accumulate.** The effective rule set is the union of every rule at every scope in the chain. There is no override and no exception list. A pod-level block cannot be cancelled by an inbox-level allow. A tighter scope can narrow what it inherits, but never loosen it. ## How a decision is made For each address being evaluated: 1. **Block rules apply first**, at every scope in the chain, regardless of mode. A match denies. No mode lets a blocked address through. 2. **In `allowlist` mode**, the address must then match an allow rule from some scope in the chain. No match denies. 3. **In `none` mode**, anything not blocked is permitted. An address matches a rule when the rule is: * an **exact email address**: `agent@partner.com` matches only that address; or * a **whole domain**: `partner.com` matches every address at `partner.com`. Matching is case-insensitive and values are stored lowercased. The domain is taken from the last `@` in the address, so a quoted local part cannot get a different domain past a block. ### Empty allowlists fail closed An `allowlist` with no allow rules denies everything. One compatibility exception: policies created through the console carry a legacy flag under which an empty **inbound** allowlist accepts all mail, and the console shows a warning instead of locking you out. Outbound is fail-closed in every case. Policies created through `PUT /v1/policy` are fail-closed in both directions. ## What is enforced, and where ### Inbound Checked against the sender's `From` address when mail arrives. Denied mail is dropped before it is threaded or stored; it never reaches your inbox, fires no [webhook](/concepts/webhooks) or [WebSocket](/concepts/websockets) event, and doesn't count toward storage. The sender gets no bounce or notification. The drop shows up as an `inbound_rejected` event in [History](#history); there is no quarantine view. ### Outbound Checked in the send path that every surface goes through, so one enforcement point covers the REST API, the CLI, the SDK, the console compose box, replies, and forwards. Every address a send can reach is checked: | Field | Checked | | --------- | ------------------------------------------------------------------------------------------- | | `to` | Yes | | `cc` | Yes, after the auto-CC merge, so a thread reply cannot add a recipient that skips the check | | `replyTo` | Yes, otherwise a reply could be routed to an address outside the approved set | | `bcc` | Not supported. See below. | One denied recipient rejects the whole send. There is no partial delivery, and a rejected send does not count against your rate limits. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} // 403 Forbidden { "error": "recipient_not_permitted", "recipient": "stranger@elsewhere.com", "message": "Sending to stranger@elsewhere.com is not permitted by this inbox's outbound recipient policy." } ``` ### Bcc is not supported OpenMail has no Bcc. A send carrying a `bcc` field is rejected rather than accepted with the field ignored. Accepting it would mean reporting success for a recipient that was never delivered to and never checked against your rules. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} // 400 Bad Request { "error": "unsupported_field", "field": "bcc", "message": "Bcc is not supported. Send to each recipient separately, or use 'cc' — every 'to', 'cc', and 'replyTo' address is checked against this inbox's outbound recipient policy." } ``` ## Who can change policy | Surface | Credential | Who | | ------------------------------ | -------------------- | ------------------------------------------------- | | Console (**Allow/Block List**) | Session | Account owner only. Members get read-only access. | | `/v1/policy` | Account-wide API key | Any holder of an account-wide key | A pod- or inbox-scoped key, the kind an agent runs with, gets `403` on every policy endpoint, reads included. An agent cannot change the rules that apply to it, and cannot see them either. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} // 403 Forbidden { "error": "forbidden", "message": "This API key is scoped and cannot manage policy." } ``` ## History Every rule change, and every send or delivery that was refused, is recorded in your Allow/Block List history. Entries can't be edited or deleted, not even over the API. | Action | Recorded when | `detail` contains | | ------------------ | ------------------------------------------------------ | --------------------------------------------- | | `rule_added` | A rule is created | `type`, `valueType`, `value` | | `rule_removed` | A rule is deleted | `type`, `valueType`, `value` | | `mode_changed` | A direction's mode changes | `from`, `to` | | `scope_deleted` | A pod or inbox is deleted, cascade-deleting its policy | `inboundMode`, `outboundMode`, `removedRules` | | `send_rejected` | Outbound policy refuses a send | `rejectedRecipient`, `reason`, `matchedScope` | | `inbound_rejected` | Inbound policy refuses delivery | `rejectedSender`, `reason`, `matchedScope` | Each event also carries its scope, the direction where one applies, and the console user who made the change. The two rejection actions have no actor; they are written by enforcement, not a person. * `scope_deleted` records the modes and rules removed when a pod or inbox is deleted, since deleting a scope also deletes its policy. * `inbound_rejected` is often the only trace of a refused delivery: the sender gets no bounce and the recipient never sees the message. Rejection events are deduplicated to one entry per inbox and address per hour, so a retry loop against a blocked address cannot flood the history. Check it in the console under **Allow/Block List → History**, or over the API: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -H "Authorization: Bearer $OPENMAIL_API_KEY" \ "https://api.openmail.sh/v1/policy/audit?action=send_rejected,inbound_rejected&since=2026-08-01T00:00:00Z" ``` ## API reference All policy endpoints require an **account-wide** API key. Select the scope with `?podId=` or `?inboxId=`; neither means the account scope. | Method | Path | Purpose | | -------- | ---------------------- | --------------------------------------------------------- | | `GET` | `/v1/policy` | Read a scope's modes, own rules, and inherited rules | | `PUT` | `/v1/policy` | Set modes and replace rules in one call, for provisioning | | `PUT` | `/v1/policy/mode` | Set one direction's mode | | `POST` | `/v1/policy/rules` | Add one allow/block rule | | `DELETE` | `/v1/policy/rules/:id` | Remove a rule | | `GET` | `/v1/policy/audit` | Read the history | ### Set up rules in one call `PUT /v1/policy` sets modes and **replaces** the scope's own rules for any direction you include, so re-running it converges to the state you declared instead of accumulating rules. Omitted directions are left untouched; pass `[]` to clear a direction's rules. The whole body is validated before anything is applied, so a bad outbound section cannot leave a half-applied inbound section behind. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X PUT "https://api.openmail.sh/v1/policy?inboxId=$INBOX_ID" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "inbound": { "mode": "allowlist", "rules": [{ "type": "allow", "value": "partner.com" }] }, "outbound": { "mode": "allowlist", "rules": [ { "type": "allow", "value": "support@partner.com" }, { "type": "allow", "value": "ops@partner.com" } ]} }' ``` Up to 500 rules per direction per call. ### Read what is in force `GET /v1/policy` returns the scope's own modes and rules plus what it inherits, so you can see what actually applies without walking the chain yourself: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "scope": "inbox", "rules": [], "inheritedRules": [], "modes": { "inbound": { "mode": "inherit", "inheritedMode": "allowlist", "inheritedSource": "account" }, "outbound": { "mode": "allowlist", "inheritedMode": "none", "inheritedSource": "default" } } } ``` `rules` are set on this scope; `inheritedRules` come from the pod and account. An inherited rule can only be removed at the scope that owns it; deleting one from a child scope returns `403 inherited_rule`. ### History query parameters | Parameter | Notes | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `action` | Comma-separated: `rule_added`, `rule_removed`, `mode_changed`, `scope_deleted`, `send_rejected`, `inbound_rejected`. An unknown name returns `400`. | | `direction` | `inbound` or `outbound` | | `podId` / `inboxId` | Events recorded at that scope, matched exactly. An inbox filter does not include the account-level changes it inherits. | | `since` / `until` | ISO 8601. `since` inclusive, `until` exclusive. An unparseable value returns `400`. | | `limit` / `offset` | Page size 1–100, default 50. The response includes an unpaginated `total`. | ## Worked example One pod per customer tenant, one inbox per agent. The account blocks what nobody should reach, the pod narrows it to the tenant, the inbox narrows it to one contact. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # 1. Account floor: block a domain everywhere. No pod or inbox can undo this. curl -X POST "https://api.openmail.sh/v1/policy/rules" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "block", "value": "known-bad.example", "direction": "outbound" }' # 2. Pod: this tenant's agents may only email within the tenant's domain. curl -X PUT "https://api.openmail.sh/v1/policy?podId=$POD_ID" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" -H "Content-Type: application/json" \ -d '{ "inbound": { "mode": "allowlist", "rules": [{ "type": "allow", "value": "tenant.com" }] }, "outbound": { "mode": "allowlist", "rules": [{ "type": "allow", "value": "tenant.com" }] } }' # 3. Inbox: this one agent talks to exactly one mailbox. curl -X PUT "https://api.openmail.sh/v1/policy?inboxId=$INBOX_ID" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" -H "Content-Type: application/json" \ -d '{ "outbound": { "mode": "allowlist", "rules": [{ "type": "allow", "value": "ap@tenant.com" }] } }' # 4. Mint the agent's key. It can read and send from this inbox, and nothing else. curl -X POST "https://api.openmail.sh/v1/inboxes/$INBOX_ID/api-keys" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "invoice-agent" }' ``` The agent's key can send only from its inbox, only to `ap@tenant.com`, and never to `known-bad.example`. It cannot read the policy, change it, or mint a broader key. Every attempt to reach outside that set is refused synchronously and recorded. ## Managing rules in the console Go to **Allow/Block List** in the [Dashboard](https://console.openmail.sh/login). The account owner can: * Add email addresses or domains to the allow or block list, per direction * Switch each direction's mode between `none`, `allowlist`, and `inherit` * Remove rules owned by the scope being viewed * Check the **History** tab to see what changed and what was refused Members see the same page read-only. ## Limits * **Domain rules do not match subdomains.** `example.com` covers `bob@example.com` but not `bob@mail.example.com`. Add each domain you want covered. * **No wildcards or regular expressions.** A rule is an exact address or an exact domain. * **Rules are address-based only.** There is no filtering on subject, body content, or attachment type. * **Denied inbound mail is dropped silently.** No bounce, no notification, no quarantine view. The drop is recorded as an `inbound_rejected` event in History. * **A value cannot be in both allow and block for the same direction at the same scope.** The API rejects this with `409`, since block always wins and the allow would be dead. Across different scopes it is permitted, and block still wins. * **No plan gating.** Sender rules are available on every plan. Sender rules work alongside [suppressions](/concepts/suppressions). Suppressions automatically block addresses that have bounced or been reported as spam. Sender rules give you manual control on top of that. # Suppressions Source: https://docs.openmail.sh/concepts/suppressions OpenMail automatically suppresses sending to addresses that have bounced or unsubscribed. Learn how suppressions work and how to manage the list. When an email bounces, a recipient marks your email as spam, or someone unsubscribes, we automatically add them to your suppression list. Subsequent send attempts to that address are rejected. ## Suppression types | Type | Trigger | What it means | | ----------- | --------------------------------- | ------------------------------------- | | `bounce` | Hard bounce from recipient server | Address is invalid or doesn't exist | | `complaint` | Recipient clicked "Report spam" | Recipient doesn't want email from you | | `unsub` | Recipient unsubscribed | Recipient opted out | ## What happens when you send to a suppressed address The send request returns `422` with a typed error: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "error": "recipient_suppressed", "type": "complaint" } ``` Your agent should handle this gracefully - for example, by noting that the contact is unreachable and trying an alternative communication channel. Suppressions are one part of maintaining good deliverability. For the full picture — warm-up strategy, inbox distribution, and content best practices — see [Email deliverability](/best-practices/email-deliverability). # Threading Source: https://docs.openmail.sh/concepts/threading OpenMail automatically groups emails into threads by subject and message headers. Learn how threading works and how to read or update thread state. OpenMail automatically groups related emails into threads using standard RFC 2822 email headers. ## How it works When an email arrives or is sent, we resolve which thread it belongs to: 1. **Check `In-Reply-To` header** - If it matches an existing message's `Message-ID`, the email joins that thread. 2. **Check `References` header** - If any value matches an existing message's `Message-ID`, the email joins that thread. 3. **No match** - A new thread is created. ## Outbound replies When you send with a `threadId`, we automatically set: * `In-Reply-To` → the `Message-ID` of the last message in the thread * `References` → all `Message-ID` values from the thread This ensures your reply threads correctly in the recipient's email client (Gmail, Outlook, etc.). We also append a quoted copy of the previous message to the body by default (the familiar `On … wrote:` block with `>`-prefixed lines). Pass `includeQuote: false` to send only your reply text. ## Thread context Fetch all messages in a thread via `GET /v1/threads/:id/messages`. Messages are returned in chronological order, giving your agent the full conversation history to inform its next action. ## Read/unread tracking Every thread has an `isRead` flag that tracks whether your agent has processed it. This prevents agents from reprocessing the same emails. ### How it works * New inbound threads start as **unread** (`isRead: false`) * Sending a reply automatically marks the thread as **read** * Your agent explicitly marks threads as read via `PATCH /v1/threads/:id` ### Recommended flow Fetch only threads your agent hasn't processed yet. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} GET /v1/inboxes/:id/threads?isRead=false ``` Fetch messages, run your agent logic, send a reply if needed. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} GET /v1/threads/:id/messages ``` After successful processing, mark the thread as read so it won't appear on the next poll. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} PATCH /v1/threads/:id { "isRead": true } ``` If your agent sends a reply, this happens automatically. If your agent uses webhooks or WebSockets, the same pattern applies — mark the thread as read after handling the event. Then use `?isRead=false` as a safety net to catch any events missed during downtime. # Overview Source: https://docs.openmail.sh/concepts/webhooks OpenMail webhooks deliver inbound email events to your endpoint in real time. Learn about delivery semantics, headers, signature verification, and retries. When an email arrives at one of your inboxes — or when the reputation engine suspends or reactivates an inbox or pod — OpenMail delivers the event to your webhook URL as an HTTP POST request. See [Events](/pages/webhooks/events) for the full list. ## Why use webhooks? Instead of constantly polling the API to check for new emails, you register a URL and OpenMail sends a `POST` request as soon as an event happens. This event-driven approach is more efficient and lets you build responsive agents that react instantly to incoming messages. * **Real-time** - Build agents that reply to emails in seconds. * **Efficient** - No polling; saves compute and simplifies your logic. ## Delivery semantics * **At-least-once delivery** - We may deliver the same event more than once. Use `event_id` to deduplicate. * **No ordering guarantee** - Events may arrive out of order. Use `occurred_at` to sort if needed. ## Webhook headers Every POST includes: | Header | Description | | ------------- | ----------------------------------------------------------------------- | | `X-Event-Id` | Stable UUID for this event. Same across retries. Use for deduplication. | | `X-Timestamp` | Unix timestamp of the delivery attempt | | `X-Signature` | HMAC-SHA256 signature for verification | ## Signature verification The signature is computed as `HMAC-SHA256(webhook_secret, "{timestamp}.{raw_json_payload}")`. Verify before processing. Use constant-time comparison to prevent timing attacks. Also verify that `X-Timestamp` is within 5 minutes of current time to prevent replay attacks. ## Retry policy If your endpoint doesn't return `2xx` within 15 seconds: | Attempt | Delay | | ------- | ---------- | | 1 | Immediate | | 2 | 30 seconds | | 3 | 60 seconds | | 4 | 2 minutes | | 5 | 4 minutes | After 5 failed attempts, the event is marked as failed. ## Full payload and verification Payload structure and field descriptions. HMAC signature verification and code examples. Configure your endpoint and implement the handler. # Overview Source: https://docs.openmail.sh/concepts/websockets OpenMail WebSockets deliver email events in real time with sub-second latency. Learn how the persistent connection model works and when to use it. WebSockets provide a persistent connection to OpenMail for receiving email events in real-time. Unlike webhooks, WebSockets don't require a public URL or external tools like ngrok. ## Why use WebSockets? | Feature | Webhook | WebSocket | | ---------- | --------------------------- | ------------------------ | | Setup | Requires public URL + ngrok | No external tools needed | | Connection | HTTP request per event | Persistent connection | | Initiation | OpenMail connects to you | You connect to OpenMail | | Firewall | Must expose port | Outbound only | | Latency | HTTP round-trip | Instant streaming | WebSockets are the recommended delivery method for agents. Agents are long-running processes that benefit from a persistent event stream rather than a callback endpoint. ## Connecting Authenticate with your API key via the `Authorization` header or `token` query parameter. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const WebSocket = require("ws"); const ws = new WebSocket("wss://api.openmail.sh/v1/ws", { headers: { Authorization: `Bearer ${process.env.OPENMAIL_API_KEY}` }, }); ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import asyncio, os from websockets import connect async def main(): uri = "wss://api.openmail.sh/v1/ws" headers = {"Authorization": f"Bearer {os.environ['OPENMAIL_API_KEY']}"} async with connect(uri, extra_headers=headers) as ws: # connected — send subscribe, receive events ... asyncio.run(main()) ``` ```bash CLI (websocat) theme={"theme":{"light":"github-light","dark":"dark-plus"}} websocat "wss://api.openmail.sh/v1/ws?token=YOUR_API_KEY" ``` The `?token=` query parameter is useful for browser clients and CLI tools that can't set custom headers on WebSocket connections. ## Delivery semantics When a customer has both an active WebSocket connection and a webhook URL configured, OpenMail prefers the WebSocket. If the WebSocket is down, events fall back to the webhook with the same retry policy. | Scenario | Delivery method | | --------------------------------------- | ------------------------------ | | WebSocket connected + subscribed | WebSocket (instant) | | WebSocket disconnected, webhook URL set | Webhook (with retries) | | Neither | Event stored, marked as failed | Events use the same [payload structure](/pages/webhooks/events) as webhooks. Emails that are blocked by [sender rules](/concepts/sender-rules) or filtered out by allowlist mode do not generate events. ## Comparison with webhooks Use **WebSockets** when: * Your agent runs locally or behind a firewall * You want instant delivery with no round-trip latency * You don't want to manage a public HTTPS endpoint Use **webhooks** when: * Your server is already publicly accessible * You need guaranteed delivery with automatic retries * You prefer stateless, request-based integration Both deliver the same event payload. You can switch between them without changing your event handling logic. Connect and receive events in under 10 lines. Message types, subscribe options, event replay, connection management. Event payload structure (shared with WebSocket). Configure webhook endpoints with signature verification. # API integration Source: https://docs.openmail.sh/guides/api-integration Create and manage inboxes programmatically via the OpenMail REST API. Ideal for multi-tenant apps, agent orchestration frameworks, and CI pipelines. For developers building AI agents with [n8n](https://n8n.io), [Make](https://make.com), or any agent framework. This guide covers the multi-tenant pattern: create inbox, inject env vars, route inbound by `inbox_id`. For LangChain or Vercel AI SDK, see [Agent frameworks](/integrations/agent-frameworks) for ready-made tool definitions. For OpenClaw, use the [channel plugin](/integrations/openclaw) instead; a pod account covers every inbox you create. ## Prerequisites * [OpenMail account](https://console.openmail.sh/login) with API key * An AI agent or agent framework (e.g. [LangChain](https://langchain.com), [n8n](https://n8n.io))
Read [https://docs.openmail.sh/guides/api-integration](https://docs.openmail.sh/guides/api-integration) then implement the OpenMail integration for our agent: 1. Create inbox - call POST /v1/inboxes, then POST /v1/inboxes//api-keys to mint an inbox-scoped key; write that key as OPENMAIL\_API\_KEY plus OPENMAIL\_INBOX\_ID and OPENMAIL\_ADDRESS into the agent container's env 2. SKILL.md file - place it in the agent's skills directory, teaches the agent to send/receive/reply via curl 3. Webhook handler at POST /webhooks/openmail - verifies X-Signature (HMAC-SHA256), routes inbound email to the correct container using inbox\_id from the payload Use our existing patterns for env var injection and container routing.
## Step 1: Create an inbox and its key Two API calls. Create the inbox, then mint an [inbox-scoped key](/concepts/inboxes#inbox-scoped-api-keys) for it. The agent gets only that key, so a compromised container can reach nothing but its own inbox. Map `inbox_id` to your user/container in your app for routing. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const fs = require("fs/promises"); const OPENMAIL_API_KEY = process.env.OPENMAIL_API_KEY; // account or pod key, stays on your backend async function api(path, body) { const response = await fetch(`https://api.openmail.sh${path}`, { method: "POST", headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(`OpenMail: ${response.status}`); return response.json(); } async function createAgentInbox(userId, containerEnvPath) { const inbox = await api("/v1/inboxes", { mailboxName: userId }); // inbox.id → "inb_8f3a1b2c" // inbox.address → "jane@omail.sh" const key = await api(`/v1/inboxes/${inbox.id}/api-keys`, { name: userId }); // key.token → "om_..." (shown once; never retrievable again) await fs.appendFile( containerEnvPath, [ `OPENMAIL_API_KEY=${key.token}`, `OPENMAIL_INBOX_ID=${inbox.id}`, `OPENMAIL_ADDRESS=${inbox.address}`, ].join("\n") + "\n" ); return inbox; } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import os, requests OPENMAIL_API_KEY = os.environ["OPENMAIL_API_KEY"] # account or pod key, stays on your backend def api(path: str, body: dict) -> dict: response = requests.post( f"https://api.openmail.sh{path}", headers={"Authorization": f"Bearer {OPENMAIL_API_KEY}"}, json=body, ) response.raise_for_status() return response.json() def create_agent_inbox(user_id: str, container_env_path: str) -> dict: inbox = api("/v1/inboxes", {"mailboxName": user_id}) # inbox["id"] → "inb_8f3a1b2c" # inbox["address"] → "jane@omail.sh" key = api(f"/v1/inboxes/{inbox['id']}/api-keys", {"name": user_id}) # key["token"] → "om_..." (shown once; never retrievable again) with open(container_env_path, "a") as f: f.write(f"OPENMAIL_API_KEY={key['token']}\n") f.write(f"OPENMAIL_INBOX_ID={inbox['id']}\n") f.write(f"OPENMAIL_ADDRESS={inbox['address']}\n") return inbox ``` Minting needs an account-wide key, or a pod key for the inbox's pod. An inbox-scoped key cannot mint. ## Step 2: Write env vars into the container Three variables per container, all unique to it. Your account or pod key never enters the container. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} OPENMAIL_API_KEY=om_... OPENMAIL_INBOX_ID=inb_8f3a1b2c OPENMAIL_ADDRESS=jane@omail.sh ``` Inject them into your agent's environment (e.g. LangChain tool config, n8n node credentials, a Docker `--env-file`). ## Step 3: Add the skill file This file teaches the agent how to send and receive email. Most frameworks load it into the system prompt or expose it as a tool description. The agent calls the API via `curl`—no CLI binary needed. Place it in your agent's skills directory (for Claude Code, `~/.claude/skills/openmail/SKILL.md`) or pre-bake it into your Docker image. The content is the same for every framework. ````markdown SKILL.md theme={"theme":{"light":"github-light","dark":"dark-plus"}} --- name: openmail description: Send and receive email via OpenMail requires: env: - OPENMAIL_API_KEY - OPENMAIL_INBOX_ID - OPENMAIL_ADDRESS --- # OpenMail Your email address is $OPENMAIL_ADDRESS. Use it when introducing yourself or sharing contact info. ## Send an email ```bash curl -s -X POST "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/send" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "to": "recipient@example.com", "subject": "Subject", "body": "Message body" }' ``` Save the `threadId` from the response to reply in the same thread later. ## Reply in a thread ```bash curl -s -X POST "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/send" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "to": "recipient@example.com", "subject": "Re: Original subject", "body": "Reply body", "threadId": "thr_..." }' ``` ## Check for new messages ```bash curl -s "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/messages?direction=inbound&limit=10" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` ## List threads ```bash curl -s "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/threads?limit=10" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` ## Read a thread ```bash curl -s "https://api.openmail.sh/v1/threads/{thread_id}/messages" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` ## Send with attachments Use multipart/form-data to attach files: ```bash curl -s -X POST "https://api.openmail.sh/v1/inboxes/$OPENMAIL_INBOX_ID/send" \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "to=recipient@example.com" \ -F "subject=Report attached" \ -F "body=See the attached file." \ -F "attachments=@/path/to/report.pdf" ``` Multiple files: repeat the `-F "attachments=@..."` flag. Max 25 MB total per email. ## Notes - Include `Idempotency-Key` when sending if your code retries (prevents duplicates; optional otherwise) - Use `threadId` when replying so the email threads correctly in the recipient's client - For attachments, use `multipart/form-data` with `-F` flags instead of `-d` JSON - `$OPENMAIL_*` variables are read from the container environment at runtime ```` **Pre-bake into Docker** - the skill file is identical for every agent. Only the env vars differ per container: `COPY skills/openmail/SKILL.md /root/.claude/skills/openmail/SKILL.md` ## Step 4: Handle inbound email Choose your delivery method. **WebSockets** are recommended for agents - no public URL needed, instant delivery. **Webhooks** work for traditional server-to-server integrations. The event payload is identical for both methods: ```json Event payload theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "event": "message.received", "event_id": "evt_7f2a3b4c", "inbox_id": "inb_8f3a1b2c", "thread_id": "thr_9d4e5f6a", "message": { "id": "msg_4c8d5e6f", "from": "customer@example.com", "to": "jane@omail.sh", "subject": "Re: Your order", "body_text": "Thanks for following up...", "attachments": [ { "filename": "receipt.pdf", "contentType": "application/pdf", "sizeBytes": 34210, "url": "https://api.openmail.sh/v1/attachments/msg_4c8d5e6f/receipt.pdf", "parsedText": "Order confirmation #1042\nTotal: $89.99", "extractionMethod": "pdf" } ], "received_at": "2026-02-24T10:05:00.000Z" } } ``` Connect to `wss://api.openmail.sh/v1/ws` from your backend with your account or pod key, so one connection covers every inbox. No public URL, no signature verification needed - the connection itself is authenticated. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const WebSocket = require("ws"); const OPENMAIL_API_KEY = process.env.OPENMAIL_API_KEY; const ws = new WebSocket("wss://api.openmail.sh/v1/ws", { headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}` }, }); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe" })); }); ws.on("message", (data) => { const event = JSON.parse(data); if (event.event === "message.received") { const container = getContainerByInboxId(event.inbox_id); container.deliverEmail({ threadId: event.thread_id, message: event.message }); } }); ws.on("close", () => setTimeout(() => connect(), 5000)); ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import asyncio, json, os, websockets OPENMAIL_API_KEY = os.environ["OPENMAIL_API_KEY"] async def listen(): uri = "wss://api.openmail.sh/v1/ws" headers = {"Authorization": f"Bearer {OPENMAIL_API_KEY}"} async for ws in websockets.connect(uri, extra_headers=headers): try: await ws.send(json.dumps({"type": "subscribe"})) async for raw in ws: event = json.loads(raw) if event.get("event") == "message.received": container = get_container_by_inbox_id(event["inbox_id"]) container.deliver_email(thread_id=event["thread_id"], message=event["message"]) except websockets.ConnectionClosed: continue asyncio.run(listen()) ``` **Subscribe options:** ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribe" } { "type": "subscribe", "inbox_ids": ["inb_8f3a1b2c"] } { "type": "subscribe", "event_types": ["message.received"] } ``` Configure your webhook URL in the [dashboard](https://console.openmail.sh/login). OpenMail POSTs events to your endpoint with HMAC-SHA256 signatures. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const crypto = require("crypto"); app.post("/webhooks/openmail", (req, res) => { const signature = req.headers["x-signature"]; const timestamp = req.headers["x-timestamp"]; const expected = crypto .createHmac("sha256", process.env.OPENMAIL_WEBHOOK_SECRET) .update(`${timestamp}.${JSON.stringify(req.body)}`) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"))) { return res.status(401).send("Invalid signature"); } const { event, inbox_id, thread_id, message } = req.body; if (event === "message.received") { const container = getContainerByInboxId(inbox_id); container.deliverEmail({ threadId: thread_id, message }); } res.status(200).send("OK"); }); ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import hmac, hashlib, os @app.post("/webhooks/openmail") def handle_webhook(): payload = request.get_data(as_text=True) timestamp = request.headers.get("X-Timestamp") signature = request.headers.get("X-Signature") expected = hmac.new( os.environ["OPENMAIL_WEBHOOK_SECRET"].encode(), f"{timestamp}.{payload}".encode(), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(signature, expected): return "Invalid signature", 401 data = request.json if data["event"] == "message.received": container = get_container_by_inbox_id(data["inbox_id"]) container.deliver_email(thread_id=data["thread_id"], message=data["message"]) return "OK", 200 ``` Always verify `X-Signature` before processing. See [Webhooks guide](/guides/webhooks) for retry policy and deduplication via `event_id`. ## Verify the integration 1. **Create inbox** - confirm `OPENMAIL_INBOX_ID` and `OPENMAIL_ADDRESS` appear in the container's env 2. **Send** - tell the agent "send an email to [your-test@email.com](mailto:your-test@email.com)" - confirm it arrives 3. **Inbound** - reply to that email; confirm the event arrives over your WebSocket connection (or webhook) and routes to the container 4. **Thread** - tell the agent "reply to the last email" - confirm the reply threads correctly in the recipient's client ## Reference | What | Where | When | | --------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ | | Skill file (SKILL.md) | Agent's skills/config dir | Pre-baked in Docker or mounted at runtime | | `OPENMAIL_API_KEY` | Agent env | Inbox-scoped key, minted when you create the inbox | | `OPENMAIL_INBOX_ID` | Agent env | Written when you create the inbox | | `OPENMAIL_ADDRESS` | Agent env | Written when you create the inbox | | WebSocket client **or** Webhook handler | Your backend | Connect to `wss://api.openmail.sh/v1/ws` or configure webhook URL in dashboard | Warm-up schedules, inbox distribution, and content best practices to stay out of spam. Real-time event streaming, subscribe protocol, reconnection. Signature verification, payload format, retries. Full endpoint documentation. # Multi-tenancy Source: https://docs.openmail.sh/guides/multi-tenancy Onboard a tenant end-to-end: create a pod, provision inboxes, and hand the tenant a pod-scoped API key that can only reach its own inboxes. Running OpenMail for many customers — a multi-tenant SaaS, an agency, an AI agent platform? Give each tenant its own [pod](/concepts/pods) and a [pod-scoped API key](/concepts/pods#pod-scoped-api-keys). The pod keeps the tenant's inboxes separate. The scoped key locks their integration to that pod: nothing outside it is reachable, and it cannot manage pods or mint keys. This guide walks the full onboarding flow — pod, domain (optional), inbox, scoped key — then inbound routing and offboarding. ## Prerequisites * An [OpenMail account](https://console.openmail.sh/login) with your **account-wide** API key. You use it to provision tenants; it never leaves your side. * A stable tenant identifier from your own system (a user ID, org ID, or workspace ID) to use as the pod's `clientId`.
Read [https://docs.openmail.sh/guides/multi-tenancy](https://docs.openmail.sh/guides/multi-tenancy) then implement per-tenant onboarding for our app: 1. On tenant signup, call POST /v1/pods with clientId set to our internal tenant ID 2. Create the tenant's inbox with POST /v1/inboxes (podId = the clientId) 3. Mint a pod-scoped key with POST /v1/pods//api-keys, store the returned token as that tenant's OPENMAIL\_API\_KEY (it is returned only once) 4. Route inbound message.received events to the tenant by inbox\_id Use the account-wide key only for steps 1–3. The tenant's own integration uses the scoped key.
## Step 1: Create a pod per tenant When a tenant signs up, create a pod and pass your own tenant ID as `clientId`. From then on you can address the pod by that ID everywhere — no mapping table on your side. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const OPENMAIL_API_KEY = process.env.OPENMAIL_API_KEY; // account-wide key async function createTenantPod(tenantId, companyName) { const res = await fetch("https://api.openmail.sh/v1/pods", { method: "POST", headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ clientId: tenantId, name: companyName }), }); if (!res.ok) throw new Error(`OpenMail: ${res.status}`); return res.json(); // { id, clientId, name, isDefault: false, ... } } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import os, requests OPENMAIL_API_KEY = os.environ["OPENMAIL_API_KEY"] # account-wide key def create_tenant_pod(tenant_id: str, company_name: str) -> dict: res = requests.post( "https://api.openmail.sh/v1/pods", headers={"Authorization": f"Bearer {OPENMAIL_API_KEY}"}, json={"clientId": tenant_id, "name": company_name}, ) res.raise_for_status() return res.json() # { "id", "clientId", "name", "isDefault": False, ... } ``` `clientId` must be unique within your account. Reuse your own primary key (tenant ID, workspace ID) and you never have to store OpenMail's pod ID. ## Step 2: Add a tenant domain (optional) For white-label setups, scope a [custom domain](/concepts/custom-domains) to the pod so the tenant's inboxes live on their own domain (e.g. `support@meridian.io`). Pass the pod when you add the domain. A pod-scoped domain can only be used by inboxes in that same pod. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} curl -X POST https://api.openmail.sh/v1/domains \ -H "Authorization: Bearer $OPENMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "meridian.io", "podId": "meridian"}' ``` Publish the DNS records OpenMail returns. The domain verifies on its own — wait until it shows as **Verified**, then create inboxes on it. You can also add and reassign domains in the dashboard under **Settings → Domains** or the pod's **Domains** tab. Skip this step to put the tenant's inboxes on your account default domain, or on an account-wide domain every pod shares. ## Step 3: Create the tenant's inboxes Create inboxes directly in the pod by passing `podId` (the pod ID or your `clientId`). An inbox belongs to exactly one pod and cannot be moved later, so pick the pod at creation. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} async function createTenantInbox(tenantId, mailboxName) { const res = await fetch("https://api.openmail.sh/v1/inboxes", { method: "POST", headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ podId: tenantId, mailboxName }), }); if (!res.ok) throw new Error(`OpenMail: ${res.status}`); return res.json(); // { id, address, podId, ... } } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} def create_tenant_inbox(tenant_id: str, mailbox_name: str) -> dict: res = requests.post( "https://api.openmail.sh/v1/inboxes", headers={"Authorization": f"Bearer {OPENMAIL_API_KEY}"}, json={"podId": tenant_id, "mailboxName": mailbox_name}, ) res.raise_for_status() return res.json() # { "id", "address", "podId", ... } ``` ## Step 4: Mint a pod-scoped API key Mint a key scoped to the tenant's pod. This is the key the tenant's integration will use. It can read and send only from this pod's inboxes. The full `token` is returned **once**, in this response, and can never be retrieved again. Store it as that tenant's secret immediately. Minting requires your account-wide key. A pod-scoped key fits a tenant that owns several inboxes and provisions more over time. If a tenant only ever has one inbox — the "one agent, one inbox" case — mint an [inbox-scoped key](/concepts/inboxes#inbox-scoped-api-keys) against that inbox instead. It cannot create or delete inboxes, which makes it the safer key to hand out when the tenant never needs to. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} async function mintTenantKey(tenantId) { const res = await fetch( `https://api.openmail.sh/v1/pods/${tenantId}/api-keys`, { method: "POST", headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: `${tenantId}-integration` }), } ); if (!res.ok) throw new Error(`OpenMail: ${res.status}`); const key = await res.json(); return key.token; // "om_..." — shown only once } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} def mint_tenant_key(tenant_id: str) -> str: res = requests.post( f"https://api.openmail.sh/v1/pods/{tenant_id}/api-keys", headers={"Authorization": f"Bearer {OPENMAIL_API_KEY}"}, json={"name": f"{tenant_id}-integration"}, ) res.raise_for_status() return res.json()["token"] # "om_..." — shown only once ``` Each pod has a cap on active keys. Hitting it returns `api_key_limit_reached` (422) — list the pod's keys with `GET /v1/pods/{id}/api-keys` and revoke an unused one first. Listing returns only a masked `tokenPrefix` and `last4`, never the token. ## Step 5: Hand the scoped key to the tenant's integration Give the scoped token to whatever runs on the tenant's behalf — a per-tenant worker, an agent container, or the tenant's own environment. The key cannot cross pods or manage pods, so it safely confines the tenant to their own inboxes: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # Send with the tenant's scoped key curl -X POST https://api.openmail.sh/v1/inboxes/INBOX_ID/send \ -H "Authorization: Bearer $TENANT_SCOPED_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"to": "customer@example.com", "subject": "Hello", "body": "..."}' # The same request against an inbox in another pod returns 403 ``` ## Handle inbound email Every inbound event carries the `inbox_id` it arrived on. Map that to the tenant and deliver — the same routing you'd use for any per-inbox integration. See [Handle inbound email](/guides/api-integration#step-4-handle-inbound-email) in the API integration guide for the full WebSocket and webhook handlers. ```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}} // event = { event: "message.received", inbox_id, thread_id, message } const tenant = getTenantByInboxId(event.inbox_id); tenant.deliver({ threadId: event.thread_id, message: event.message }); ``` Subscribe with your account-wide key to receive events across every pod, then fan out by `inbox_id`. A pod-scoped key's subscription is filtered to its own pod, so a per-tenant worker only sees its tenant's events. One caveat: a pod-scoped subscribe-all covers the pod's inboxes at subscribe time — re-subscribe after creating an inbox to stream its events. ## Full onboarding flow Putting the steps together — one function that provisions a tenant from scratch and returns the scoped key to store: ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} async function onboardTenant(tenantId, companyName) { await createTenantPod(tenantId, companyName); // Step 1 const inbox = await createTenantInbox(tenantId, "support"); // Step 3 const scopedKey = await mintTenantKey(tenantId); // Step 4 // Store scopedKey as this tenant's OPENMAIL_API_KEY (shown only once) return { podId: tenantId, address: inbox.address, scopedKey }; } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} def onboard_tenant(tenant_id: str, company_name: str) -> dict: create_tenant_pod(tenant_id, company_name) # Step 1 inbox = create_tenant_inbox(tenant_id, "support") # Step 3 scoped_key = mint_tenant_key(tenant_id) # Step 4 # Store scoped_key as this tenant's OPENMAIL_API_KEY (shown only once) return {"pod_id": tenant_id, "address": inbox["address"], "scoped_key": scoped_key} ``` ## Offboarding a tenant Revoke the tenant's key first, then clean up their pod. Revocation is immediate — the key returns `401` on its next request. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # 1. Revoke the tenant's scoped key curl -X DELETE https://api.openmail.sh/v1/pods/meridian/api-keys/KEY_ID \ -H "Authorization: Bearer $OPENMAIL_API_KEY" # 2. Delete the pod's inboxes, reassign any pod-scoped domains, then delete the pod curl -X DELETE https://api.openmail.sh/v1/pods/meridian \ -H "Authorization: Bearer $OPENMAIL_API_KEY" ``` A pod must be empty and own no pod-scoped domains before it can be deleted. See [Lifecycle](/concepts/pods#lifecycle) in the pods concept page for the full teardown order. ## Reference | What | Endpoint | Key required | | ------------------------------------------------------- | --------------------------------------- | -------------------------- | | Create a tenant's pod | `POST /v1/pods` | Account-wide | | Add a tenant domain (optional) | `POST /v1/domains` (with `podId`) | Account-wide or pod-scoped | | Create inboxes in the pod | `POST /v1/inboxes` (with `podId`) | Account-wide | | Mint the tenant's scoped key | `POST /v1/pods/{id}/api-keys` | Account-wide | | List a pod's keys (masked) | `GET /v1/pods/{id}/api-keys` | Account-wide | | Revoke a key | `DELETE /v1/pods/{id}/api-keys/{keyId}` | Account-wide | | Set the tenant's [sender rules](/concepts/sender-rules) | `PUT /v1/policy?podId={id}` | Account-wide | | Review rule changes and refused sends | `GET /v1/policy/audit` | Account-wide | | Send / receive within a pod | `POST /v1/inboxes/{id}/send`, etc. | Pod-scoped or account-wide | The full pods model: isolation, `clientId`, patterns, and lifecycle. Account-wide, pod-scoped, and inbox-scoped keys, and the token-once rule. Scope a domain to one pod for white-label tenant addresses. Per-inbox provisioning, skill files, and inbound routing. # Setup Source: https://docs.openmail.sh/guides/webhooks Configure webhook endpoints to receive inbound email events from OpenMail in real time. Covers endpoint setup, payload format, and signature verification. Configure a webhook URL in the [Dashboard](https://console.openmail.sh/login). When inbound email arrives, OpenMail POSTs to your endpoint. ## Implementation flow 1. Receive POST at your endpoint. 2. Read raw body, `X-Timestamp`, `X-Signature`, `X-Event-Id`. 3. [Verify the signature](/pages/webhooks/verification) before processing. 4. Parse JSON, check `event === "message.received"`. 5. Use `inbox_id` to route to the right agent/container. 6. Return `200` within 15 seconds. ## Local development with ngrok For local development, you need a public URL so OpenMail can reach your machine. [ngrok](https://ngrok.com/) creates a secure tunnel from a public URL to your local server. 1. Install ngrok: `brew install ngrok` (macOS) or download from [ngrok.com](https://ngrok.com) 2. Sign up and add your authtoken: `ngrok config add-authtoken YOUR_AUTHTOKEN` 3. Start your webhook server (see examples below) 4. In another terminal: `ngrok http 3000` 5. Copy the `https://` forwarding URL (e.g. `https://abc123.ngrok-free.app`) 6. In the [Dashboard](https://console.openmail.sh/login) → **Settings**, set webhook URL to `https://abc123.ngrok-free.app/webhooks` 7. Copy your webhook secret into `.env` as `OPENMAIL_WEBHOOK_SECRET` Free ngrok accounts have 2-hour session limits. When the tunnel disconnects, restart ngrok and update the webhook URL in the dashboard. ## Full server examples ```python Python (Flask) theme={"theme":{"light":"github-light","dark":"dark-plus"}} import os import json import hmac import hashlib import time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["OPENMAIL_WEBHOOK_SECRET"] def verify_webhook(payload: bytes, timestamp: str, signature: str) -> bool: message = f"{timestamp}.{payload.decode()}".encode() expected = hmac.new(SECRET.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected) @app.route("/webhooks", methods=["POST"]) def webhook(): payload = request.get_data() timestamp = request.headers.get("X-Timestamp", "") signature = request.headers.get("X-Signature", "") if not verify_webhook(payload, timestamp, signature): return "", 400 if abs(time.time() - int(timestamp)) > 300: return "", 400 data = json.loads(payload.decode()) if data.get("event") == "message.received": # Route by inbox_id, process async inbox_id = data.get("inbox_id") message = data.get("message", {}) # ... handle message return "", 200 if __name__ == "__main__": app.run(port=3000) ``` ```javascript Node.js (Express) theme={"theme":{"light":"github-light","dark":"dark-plus"}} const express = require("express"); const crypto = require("crypto"); const app = express(); const SECRET = process.env.OPENMAIL_WEBHOOK_SECRET; function verifyWebhook(payload, timestamp, signature) { const expected = crypto .createHmac("sha256", SECRET) .update(`${timestamp}.${payload}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") ); } app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => { const payload = req.body.toString(); const timestamp = req.headers["x-timestamp"] || ""; const signature = req.headers["x-signature"] || ""; if (!verifyWebhook(payload, timestamp, signature)) { return res.status(400).send(); } if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) { return res.status(400).send(); } const data = JSON.parse(payload); if (data.event === "message.received") { const { inbox_id, message } = data; // ... handle message } res.status(200).send(); }); app.listen(3000); ``` Use `express.raw()` for the webhook route, not `express.json()`. Signature verification requires the exact raw body. ## Testing locally 1. Start your server and ngrok. 2. Set the webhook URL and secret in the dashboard. 3. Use **Test webhook** in **Settings** to send a test event. 4. Or send an email to one of your inbox addresses and watch the console. ## Production deployment Deploy your webhook server to a hosting provider with a stable public HTTPS URL. Options include [Render](https://render.com/), [Railway](https://railway.app/), [Fly.io](https://fly.io/), [Vercel](https://vercel.com/) (serverless), or any cloud provider. * Set `OPENMAIL_WEBHOOK_SECRET` as an environment variable. * Update the webhook URL in the dashboard to your production URL. * Always verify signatures in production - never skip verification. ## Best practices * **Respond quickly** - Return `200` within 15 seconds. Process asynchronously if needed. * **Idempotency** - Use `event_id` to deduplicate. We may retry; your handler should be idempotent. * **Attachment URLs** - Fetch promptly; signed URLs expire. * **Verify signatures** - Never process webhooks without verifying. ## Troubleshooting | Issue | Solution | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Signature verification fails | Use the raw request body, not parsed JSON. Ensure secret matches the dashboard. Check `X-Timestamp` is within 5 minutes. | | Webhook not receiving events | Verify ngrok is running and the forwarding URL matches the dashboard. Ensure your server is listening on the correct port. | | Port already in use | Change the port in your server and ngrok: `ngrok http 4000` | | ngrok tunnel disconnects | Free accounts have 2-hour limits. Restart ngrok and update the webhook URL in the dashboard. | ## Related Payload structure and field descriptions. HMAC formula and verification steps. Delivery semantics, retry policy. # Quickstart Source: https://docs.openmail.sh/guides/websockets/quickstart Connect to OpenMail's WebSocket endpoint and start receiving inbound email events in real time. Step-by-step guide with authentication and code examples. ## Minimal example Connect, subscribe, and log incoming events. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const WebSocket = require("ws"); const ws = new WebSocket("wss://api.openmail.sh/v1/ws", { headers: { Authorization: `Bearer ${process.env.OPENMAIL_API_KEY}` }, }); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe" })); }); ws.on("message", (data) => { const event = JSON.parse(data); if (event.type === "subscribed") { console.log("Subscribed — inboxes:", event.inbox_ids, "events:", event.event_types); } else if (event.event === "message.received") { console.log(`From: ${event.message.from}`); console.log(`Subject: ${event.message.subject}`); console.log(`Body: ${event.message.body_text}`); } }); ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import asyncio, json, os from websockets import connect async def main(): uri = "wss://api.openmail.sh/v1/ws" headers = {"Authorization": f"Bearer {os.environ['OPENMAIL_API_KEY']}"} async with connect(uri, extra_headers=headers) as ws: await ws.send(json.dumps({"type": "subscribe"})) async for raw in ws: event = json.loads(raw) if event.get("type") == "subscribed": print("Subscribed — inboxes:", event["inbox_ids"], "events:", event["event_types"]) elif event.get("event") == "message.received": print(f"From: {event['message']['from']}") print(f"Subject: {event['message']['subject']}") print(f"Body: {event['message']['body_text']}") asyncio.run(main()) ``` *** ## Production example Adds reconnection with exponential backoff and `last_event_id` replay to avoid losing events during disconnects. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const WebSocket = require("ws"); const OPENMAIL_API_KEY = process.env.OPENMAIL_API_KEY; let lastEventId = null; let retries = 0; function connect() { const ws = new WebSocket("wss://api.openmail.sh/v1/ws", { headers: { Authorization: `Bearer ${OPENMAIL_API_KEY}` }, }); ws.on("open", () => { console.log("Connected to OpenMail"); const sub = { type: "subscribe" }; if (lastEventId) sub.last_event_id = lastEventId; ws.send(JSON.stringify(sub)); retries = 0; }); ws.on("message", (data) => { const event = JSON.parse(data); if (event.type === "subscribed") { console.log("Subscribed — inboxes:", event.inbox_ids, "events:", event.event_types); return; } if (event.event === "message.received") { lastEventId = event.event_id; console.log(`New email from: ${event.message.from}`); console.log(`Subject: ${event.message.subject}`); const container = getContainerByInboxId(event.inbox_id); container.deliverEmail({ threadId: event.thread_id, message: event.message, }); } }); ws.on("close", (code) => { console.log(`Disconnected (${code}), reconnecting...`); setTimeout(connect, Math.min(1000 * Math.pow(2, retries++), 30000)); }); ws.on("error", (err) => { console.error("WebSocket error:", err.message); }); } connect(); ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import asyncio, json, os from websockets import connect OPENMAIL_API_KEY = os.environ["OPENMAIL_API_KEY"] last_event_id = None async def listen(): global last_event_id uri = "wss://api.openmail.sh/v1/ws" headers = {"Authorization": f"Bearer {OPENMAIL_API_KEY}"} async for ws in connect(uri, extra_headers=headers): try: sub = {"type": "subscribe"} if last_event_id: sub["last_event_id"] = last_event_id await ws.send(json.dumps(sub)) async for raw in ws: event = json.loads(raw) if event.get("type") == "subscribed": print("Subscribed — inboxes:", event["inbox_ids"], "events:", event["event_types"]) continue if event.get("event") == "message.received": last_event_id = event["event_id"] print(f"New email from: {event['message']['from']}") print(f"Subject: {event['message']['subject']}") container = get_container_by_inbox_id(event["inbox_id"]) container.deliver_email( thread_id=event["thread_id"], message=event["message"], ) except Exception: continue # auto-reconnects asyncio.run(listen()) ``` Why WebSockets, connecting, delivery semantics. Message types, subscribe options, event replay, connection management. Full field descriptions for the event payload. # Welcome Source: https://docs.openmail.sh/index Welcome to OpenMail — email infrastructure for AI agents. Start with the quickstart, explore core concepts, or jump straight into the API reference. Give your AI agent its own email address. Install the CLI, run setup, and your agent can send, receive, and reply in threads immediately. A hand launching a paper plane, rendered in high-contrast vertical scanlines. ## Get started Pick your agent framework and get it an email address. Every `openmail` command, for agents that work from a shell. Full API with interactive examples. Create inboxes and send mail programmatically. ## Explore Dedicated addresses, scoped keys, and inbound routing. Real-time event streaming. No public URL needed. Server-to-server event delivery with retries. OpenClaw, Hermes Agent, Claude Code, and agent frameworks. ## Need help Ask in the OpenMail community. Write to [support@openmail.to](mailto:support@openmail.to). Pricing, domains, limits, and common setup questions. # Agent frameworks Source: https://docs.openmail.sh/integrations/agent-frameworks Integrate OpenMail with LangChain, Vercel AI SDK, or any tool-calling framework. Includes examples for inbox creation and inbound email handling. Give your agent email tools that call the OpenMail REST API directly — no CLI dependency needed. ## Prerequisites * [OpenMail account](https://console.openmail.sh/login) with API key * An inbox already created (see [API integration — Step 1](/guides/api-integration#step-1-create-an-inbox)) Set these environment variables before running any of the examples below: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} OPENMAIL_API_KEY=om_... OPENMAIL_INBOX_ID=inb_... OPENMAIL_ADDRESS=agent@example.omail.sh ``` ## Tools to implement | Tool | What it does | | ------------- | ------------------------------------------------ | | `send_email` | Send an email (or reply in a thread) | | `check_inbox` | List unread threads | | `read_thread` | Get all messages in a thread and mark it as read | *** ## LangChain (Python) ```python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import os import uuid import requests from langchain_core.tools import tool API_KEY = os.environ["OPENMAIL_API_KEY"] INBOX_ID = os.environ["OPENMAIL_INBOX_ID"] BASE = "https://api.openmail.sh/v1" HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} @tool def send_email(to: str, subject: str, body: str, thread_id: str | None = None) -> dict: """Send an email. Pass thread_id to reply in an existing thread.""" payload = {"to": to, "subject": subject, "body": body} if thread_id: payload["threadId"] = thread_id resp = requests.post( f"{BASE}/inboxes/{INBOX_ID}/send", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json=payload, ) resp.raise_for_status() return resp.json() @tool def check_inbox() -> list[dict]: """List unread threads — use this to check for new mail.""" resp = requests.get( f"{BASE}/inboxes/{INBOX_ID}/threads", headers=HEADERS, params={"isRead": "false", "limit": "20"}, ) resp.raise_for_status() return resp.json() @tool def read_thread(thread_id: str) -> list[dict]: """Get all messages in a thread (oldest first) and mark it as read.""" resp = requests.get( f"{BASE}/threads/{thread_id}/messages", headers=HEADERS, ) resp.raise_for_status() messages = resp.json() requests.patch( f"{BASE}/threads/{thread_id}", headers=HEADERS, json={"isRead": True}, ) return messages ``` ### Wire the agent ```python theme={"theme":{"light":"github-light","dark":"dark-plus"}} from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent agent = create_react_agent( ChatOpenAI(model="gpt-4o"), tools=[send_email, check_inbox, read_thread], ) result = agent.invoke( {"messages": [{"role": "user", "content": "Check my inbox and summarise any new emails."}]} ) ``` *** ## Vercel AI SDK (TypeScript) ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}} import { tool } from "ai"; import { z } from "zod"; const API_KEY = process.env.OPENMAIL_API_KEY!; const INBOX_ID = process.env.OPENMAIL_INBOX_ID!; const BASE = "https://api.openmail.sh/v1"; const headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }; const sendEmail = tool({ description: "Send an email. Pass threadId to reply in an existing thread.", inputSchema: z.object({ to: z.string().describe("Recipient email address"), subject: z.string().describe("Email subject"), body: z.string().describe("Plain text body"), threadId: z.string().optional().describe("Thread ID to reply in"), }), execute: async ({ to, subject, body, threadId }) => { const resp = await fetch(`${BASE}/inboxes/${INBOX_ID}/send`, { method: "POST", headers: { ...headers, "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ to, subject, body, ...(threadId && { threadId }) }), }); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); return resp.json(); }, }); const checkInbox = tool({ description: "List unread threads — use this to check for new mail.", inputSchema: z.object({}), execute: async () => { const resp = await fetch( `${BASE}/inboxes/${INBOX_ID}/threads?isRead=false&limit=20`, { headers }, ); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); return resp.json(); }, }); const readThread = tool({ description: "Get all messages in a thread (oldest first) and mark it as read.", inputSchema: z.object({ threadId: z.string().describe("Thread ID to read"), }), execute: async ({ threadId }) => { const resp = await fetch(`${BASE}/threads/${threadId}/messages`, { headers }); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); const messages = await resp.json(); await fetch(`${BASE}/threads/${threadId}`, { method: "PATCH", headers, body: JSON.stringify({ isRead: true }), }); return messages; }, }); ``` ### Wire the agent ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}} import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; const result = streamText({ model: openai("gpt-4o"), tools: { sendEmail, checkInbox, readThread }, maxSteps: 5, prompt: "Check my inbox and summarise any new emails.", }); for await (const part of result.textStream) { process.stdout.write(part); } ``` *** ## Generic (fetch) For any framework that supports function calling — define these as your tool implementations. ```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}} const API_KEY = process.env.OPENMAIL_API_KEY; const INBOX_ID = process.env.OPENMAIL_INBOX_ID; const BASE = "https://api.openmail.sh/v1"; const headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }; async function sendEmail({ to, subject, body, threadId }) { const resp = await fetch(`${BASE}/inboxes/${INBOX_ID}/send`, { method: "POST", headers: { ...headers, "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ to, subject, body, ...(threadId && { threadId }) }), }); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); return resp.json(); } async function checkInbox() { const resp = await fetch( `${BASE}/inboxes/${INBOX_ID}/threads?isRead=false&limit=20`, { headers }, ); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); return resp.json(); } async function readThread(threadId) { const resp = await fetch(`${BASE}/threads/${threadId}/messages`, { headers }); if (!resp.ok) throw new Error(`OpenMail ${resp.status}`); const messages = await resp.json(); await fetch(`${BASE}/threads/${threadId}`, { method: "PATCH", headers, body: JSON.stringify({ isRead: true }), }); return messages; } ``` Pass these functions as tool implementations in your framework's tool-calling API. The JSON schemas for the LLM are: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} [ { "name": "send_email", "description": "Send an email. Pass threadId to reply in an existing thread.", "parameters": { "type": "object", "properties": { "to": { "type": "string", "description": "Recipient email address" }, "subject": { "type": "string", "description": "Email subject" }, "body": { "type": "string", "description": "Plain text body" }, "threadId": { "type": "string", "description": "Thread ID to reply in" } }, "required": ["to", "subject", "body"] } }, { "name": "check_inbox", "description": "List unread threads — use this to check for new mail.", "parameters": { "type": "object", "properties": {} } }, { "name": "read_thread", "description": "Get all messages in a thread (oldest first) and mark it as read.", "parameters": { "type": "object", "properties": { "threadId": { "type": "string", "description": "Thread ID to read" } }, "required": ["threadId"] } } ] ``` *** ## Inbound email The tools above let your agent poll for new mail with `check_inbox`. For real-time delivery, connect a **WebSocket** listener or configure a **webhook** — both push `message.received` events as they arrive. See the [API integration guide — Step 4](/guides/api-integration#step-4-handle-inbound-email) for full WebSocket and webhook code examples with Node.js and Python. *** ## Related Create inboxes, inject env vars, and handle inbound for multi-tenant apps. Full endpoint documentation. Real-time event streaming — no public URL needed. Sending and receiving files. # Claude Code Source: https://docs.openmail.sh/integrations/claude-code Give your Claude Code agent a real email address. Install the OpenMail CLI, create an inbox, and add the openmail skill so Claude Code can send, read, and reply. Claude Code uses the OpenMail CLI directly. The `openmail` skill in the [skills repo](https://github.com/openmailsh/skills/blob/main/skills/openmail/SKILL.md) teaches it the commands. The same setup works for Cursor and Codex. ## Get started ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} npm install -g @openmail/cli export OPENMAIL_API_KEY=om_... ``` Requires Node.js 20+. Get the key from the [console](https://console.openmail.sh/api-keys). ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail init --mailbox-name my-agent --display-name "My agent" ``` Creates the inbox and saves it as the default in `~/.openmail-cli/state.json`, so `send`, `threads`, and `messages` work without `--inbox-id`. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} npx skills add openmailsh/skills ``` Or copy `skills/openmail/SKILL.md` from the [skills repo](https://github.com/openmailsh/skills) into `~/.claude/skills/openmail/`. For Cursor or Codex, use that agent's skills directory. Ask Claude Code to send a test email. It runs `openmail send` and returns the `threadId`. ## Narrow the key `OPENMAIL_API_KEY` is whatever you export. An account-wide key reaches every inbox, so once the inbox exists, swap in an inbox-scoped key: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail inbox keys create --inbox-id --name claude-code --json ``` Run this from your own shell, not the agent's. Why and how to rotate: [Agent keys](/best-practices/api-key-scopes). ## What the skill covers * Sending and replying in-thread with `openmail send` * Checking for new mail with `openmail threads list --is-read false` * Reading attachments as text with `openmail attachments text` * Inbound and outbound policy with `openmail policy` * Treating email bodies as untrusted data Inbound mail does not wake Claude Code on its own. The skill polls with `threads list` when you ask it to wait for a reply. For push delivery, use [WebSockets](/concepts/websockets) or [webhooks](/guides/webhooks) from your own code. ## Related * [Quickstart](/quickstart): Claude Code tab with copy-paste steps * [OpenClaw](/integrations/openclaw) and [Hermes Agent](/integrations/hermes): native plugins with inbound delivery * [API integration](/guides/api-integration): multi-tenant or non-CLI setups # CLI Source: https://docs.openmail.sh/integrations/cli Every openmail command with a runnable example, for agents that work from a shell. [`@openmail/cli`](https://github.com/openmailsh/cli) is how agents that live in a shell talk to OpenMail: Claude Code, Cursor, Codex, or a cron job. On OpenClaw the [plugin](/integrations/openclaw) bundles it, so nothing on this page needs installing there. ## Install Needs Node 20 or newer. ```bash npm theme={"theme":{"light":"github-light","dark":"dark-plus"}} npm install -g @openmail/cli ``` ```bash pnpm theme={"theme":{"light":"github-light","dark":"dark-plus"}} pnpm add -g @openmail/cli ``` ```bash bun theme={"theme":{"light":"github-light","dark":"dark-plus"}} bun add -g @openmail/cli ``` ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail init ``` `init` creates an inbox, stores it as the default, and saves the key you gave it to `~/.openmail-cli/state.json` (mode 600). Every command that takes `--inbox-id` falls back to that default. ## Authentication Key resolution, first match wins: `--api-key`, `OPENMAIL_API_KEY` (environment or a `.env` in the working directory), then the key `init` saved. In CI or anywhere commands get logged, use the environment variable. `--api-key` shows up in process lists and shell history. Key scope decides what works: | Scope | Can | Can't | | ------- | ----------------------------------------------------------------- | ------------------------------------------------ | | Inbox | Read, send, and handle attachments and threads for that one inbox | Create inboxes, mint keys, read or change policy | | Pod | Everything inside its pod, including new inboxes | Anything outside the pod; set policy to `none` | | Account | Everything | | ## Commands `openmail help ` prints the full flag list for any group. What follows is one working example per thing you'd do. ### Send ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail send --to marc@example.com --subject "Thursday" --body "Thursday works." # new thread openmail send --to marc@example.com --cc jonas@example.com --subject "Hi" --body "

Hi

" # cc, HTML body openmail send --to marc@example.com --thread-id thr_xxx --body "Confirmed." --no-quote # reply, no quoted history openmail send --to marc@example.com --subject "Report" --body "Attached." --attach report.pdf --attach data.csv ``` One `--to`, on purpose: the CLI refuses a second so an agent can't spray. Repeat `--cc` for the others. HTML in `--body` is detected. With `--thread-id` the subject is optional and the API quotes the previous message under your text the way a mail client does; `--no-quote` sends your text alone, handy when the chain is long. `--reply-to` on the free plan has to be an inbox you own; `--idempotency-key` makes retries safe. ### Read ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail threads list --is-read false --json # unread threads: the polling loop openmail threads get --thread-id thr_xxx --json # full thread with message ids and attachments openmail threads read --thread-id thr_xxx # mark read (unread puts it back) openmail messages list --direction inbound --limit 20 --json ``` Run `threads list --is-read false`, handle what comes back, mark each thread read. ### Attachments ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail attachments text --message-id msg_xxx --filename report.pdf # extracted text openmail attachments get --message-id msg_xxx --filename report.pdf --out /tmp/report.pdf # raw file ``` `text` returns extracted text for PDF, DOCX, XLSX, PPTX, and images (OCR), so the agent reads the document without parsing it. `get` downloads the raw file. Ids and filenames come from `threads get`. ### Inboxes ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail inbox create --mailbox-name sales --display-name "Sales bot" --json # sales@omail.sh openmail inbox create --mailbox-name hello --domain mail.example.com # on a verified domain openmail inbox list --json openmail inbox update --inbox-id inb_xxx --display-name "Sales" openmail inbox delete --inbox-id inb_xxx ``` `--domain` needs a verified custom domain. A pod-scoped key always creates in its own pod; account keys pass `--pod-id`. Inbox keys and webhooks live here too: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail inbox keys create --inbox-id inb_xxx --name research --json openmail inbox keys list --inbox-id inb_xxx openmail inbox keys revoke --inbox-id inb_xxx --key-id key_xxx openmail inbox webhook set --inbox-id inb_xxx --url https://example.com/hooks/openmail openmail inbox webhook test --inbox-id inb_xxx openmail inbox webhook rotate-secret --inbox-id inb_xxx openmail inbox webhook clear --inbox-id inb_xxx ``` Key tokens print once. Minting needs an account or pod key. Webhooks need an account key; if you only want to notice new mail, `threads list --is-read false` does it without one. ### Pods ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail pod create --name research --client-id agent-7 --json # your id works wherever pod_id does openmail pod keys create --pod-id pod_xxx --name research --json # token prints once openmail pod list openmail pod update --pod-id pod_xxx --name "Research (EU)" openmail pod keys revoke --pod-id pod_xxx --key-id key_xxx openmail pod delete --pod-id pod_xxx ``` One pod per agent or per tenant, each with its own pod-scoped key, is the usual shape. `--client-id` is your own id for the pod and works anywhere a `pod_id` does. Pod management needs an account key. See [Pods](/concepts/pods). ### Domains ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail domain add --domain mail.example.com # returns DNS records to publish openmail domain verify --domain-id dom_xxx # re-checks them openmail domain list openmail domain get --domain-id dom_xxx openmail domain delete --domain-id dom_xxx ``` `--pod-id` on `add` ties the domain to one pod; leave it off for account-wide. ### Policy ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail policy get --inbox-id inb_xxx openmail policy mode --inbox-id inb_xxx --direction inbound --mode allowlist # empty allowlist denies all openmail policy allow --inbox-id inb_xxx --direction inbound --value marc@example.com openmail policy block --direction outbound --value "*.competitor.com" # account-wide openmail policy rules remove --rule-id rule_xxx --inbox-id inb_xxx openmail policy audit --direction inbound --since 2026-09-01T00:00:00Z ``` Who may email an inbox (inbound) and who it may email (outbound, checked on To, Cc, and Reply-To). Scope defaults to the account; `--pod-id` or `--inbox-id` narrows it. Modes are `none`, `allowlist`, and `inherit`; an empty allowlist denies everyone. Inbox keys can't touch policy. See [Sender rules](/concepts/sender-rules). ### Feedback ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail feedback --type bug --message "send returned 500 for plain text" --endpoint /v1/inboxes/{id}/send --error-code internal_error openmail feedback --type feature_request --message "search messages by subject" ``` Goes to the OpenMail team. Types: `bug`, `friction`, `feature_request`. ## Global flags | Flag | Effect | | ------------------ | ---------------------------------------------------------------------- | | `--json` | JSON output and logs. Use it whenever an agent parses the result. | | `--verbose` | Request-level logging. | | `--api-key ` | Overrides `OPENMAIL_API_KEY` and the saved key. | | `--base-url ` | Overrides `OPENMAIL_BASE_URL`. Default `https://api.openmail.sh`. | | `--state-path

` | Overrides `OPENMAIL_STATE_PATH`. Default `~/.openmail-cli/state.json`. | | `--version` | Print the installed version. | | `--help` | Help for any command: `openmail help send`. | ## Environment variables | Variable | Default | | --------------------- | ---------------------------------------- | | `OPENMAIL_API_KEY` | none; falls back to the key `init` saved | | `OPENMAIL_BASE_URL` | `https://api.openmail.sh` | | `OPENMAIL_STATE_PATH` | `~/.openmail-cli/state.json` | Read from the environment first, then a `.env` in the working directory. The matching flag wins over both. ## Exit codes `0` on success, `1` on anything else: bad flags, missing key, API errors. With `--json` the error is a JSON object on stderr, so scripts can branch on the code and parse the reason. ## Updating ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail update openmail --version ``` The CLI checks npm at most once a day and prints a one-line notice after any command when a newer version exists. `update` runs `npm install -g` for you; `upgrade` is an alias. ## Limits * **One page per list call.** `--limit` and `--offset` only. Nothing auto-paginates. * **No confirmation on delete or revoke.** `inbox delete` and `keys revoke` run as soon as you press enter, which is what an agent wants and what a typo doesn't. ## Source and support The CLI is open source at [openmailsh/cli](https://github.com/openmailsh/cli); bugs and feature requests go in its [issues](https://github.com/openmailsh/cli/issues), or straight from the terminal with `openmail feedback`. ## Related * [OpenClaw](/integrations/openclaw): the plugin that bundles this CLI * [Claude Code](/integrations/claude-code): the skill that teaches it these commands * [API reference](/api-reference/introduction): what each command calls # Hermes Agent Source: https://docs.openmail.sh/integrations/hermes Give your Hermes agent an email address. Install [`openmailsh/hermes-plugin`](https://github.com/openmailsh/hermes-plugin) and mail to the agent's address wakes it. Replies go out in the same thread. ## Before you start * Hermes Agent, installed and working. * An OpenMail API key from the [console](https://console.openmail.sh/api-keys). Any scope works. ## Install ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes plugins install openmailsh/hermes-plugin --enable ``` ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes openmail setup ``` Paste the key. Setup picks your inbox, or creates one. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes gateway run ``` Already running? `hermes gateway restart`. Look for `[OpenMail] Connected as

` in the log. Email the agent and it answers in the thread. Or ask it on your usual chat: * "What's in the inbox since this morning?" * "Reply to Marc that Thursday works, and cc Jonas." * "Sign up for the Linear newsletter and give me the confirmation code." Something off? `hermes openmail doctor`. ## Modes | Mode | When mail arrives | Good for | | --------- | ------------------------------------------------------------------- | ---------------------------------------- | | `channel` | The agent replies in the thread. | An inbox that *is* the agent: `support@` | | `notify` | The agent summarises it to your home channel and does nothing else. | Your own inbox | | `tool` | Nothing. The agent reads the inbox when you ask. | "Sign up for X and tell me the code" | Setup picks `channel`. Change it with `OPENMAIL_MODE` in `~/.hermes/.env`, or [per inbox](#several-inboxes). `notify` needs a home channel: `hermes gateway setup`. Only mail from a person gets a reply. Notifications and verification codes reach the agent as information, so it never answers `noreply@`. Spam never reaches it. ## Who can email the agent Anyone, by default. Restrict it under **Allow/Block List** in the [console](https://console.openmail.sh/sender-rules) or with the CLI: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail policy mode --direction inbound --mode allowlist --inbox-id openmail policy allow --direction inbound --value marc@example.com --inbox-id ``` Blocked mail is dropped on the server; the agent never sees it. Hermes's own `GATEWAY_ALLOWED_USERS` applies too. ### Prompt injection Replies go to the sender OpenMail recorded, not one the message claims. The model never sees the API key. Inbound mail can't issue gateway commands. The agent can still start new threads; to stop that, make the inbox's outbound policy an allowlist. ## What the agent can do | Tool | Does | | --------------------------- | --------------------------------------- | | `openmail_whoami` | Its inboxes and default | | `openmail_send` | Start a thread, with cc and attachments | | `openmail_reply` | Reply in a thread | | `openmail_list_threads` | Threads, newest first | | `openmail_read_thread` | Every message in a thread | | `openmail_list_messages` | Messages, inbound or outbound | | `openmail_attachment_text` | Text from PDF, DOCX, XLSX, images | | `openmail_list_inboxes` | Inboxes the key can see | | `openmail_create_inbox` | New inbox (pod or account key) | | `openmail_create_inbox_key` | Key for a subagent | Tools default to the agent's own inbox. The bundled [`openmail` skill](/integrations/skill-files/openmail) teaches it the [CLI](/integrations/cli) for everything else; install the CLI separately. ## Several inboxes Say yes when setup offers the whole [pod](/concepts/pods), and the agent runs every inbox in it, including ones it creates later. Each sender gets a conversation per inbox; replies leave from the inbox that received the mail. Set modes per inbox in `~/.hermes/config.yaml`: ```yaml theme={"theme":{"light":"github-light","dark":"dark-plus"}} platforms: openmail: inboxes: sales@omail.sh: { mode: channel } alerts@omail.sh: { mode: notify } ``` ## Subagents and Bots With a pod key, the agent can create an inbox, mint a key for it, and hand it to a child. `delegate_task` children inherit the parent's setup and can do this themselves. Give each [Bot](https://hermes-agent.nousresearch.com/docs/user-guide/bot-mode) its own inbox key and it runs as its own address. ## Attachments Text from PDF, DOCX, XLSX, PPTX, CSV, and images (OCR) reaches the agent inline, up to 8k characters per file and 24k total. Files without text arrive as files, so a vision model sees the picture. Outbound, the agent attaches local files by path. ## Scheduled jobs `--deliver openmail` on a cron job emails its output to you; `--deliver openmail:alice@x.com` to anyone. Works with the gateway stopped. ## Reliability A restart replays missed mail and keeps replies in-thread. A rejected key stops the plugin instead of retrying; `hermes openmail doctor` says why. ## Configuration reference Setup writes `~/.hermes/.env`. If you gave it an account key, it stores a narrower pod key instead. Each variable also works under `platforms.openmail` in `config.yaml`, lower-cased without the prefix. | Variable | Meaning | | -------------------------- | ------------------------------------------------------------ | | `OPENMAIL_API_KEY` | Inbox, pod, or account key | | `OPENMAIL_INBOX_ID` | Inbox to run as. Only needed when the key spans several pods | | `OPENMAIL_POD_ID` | Run every inbox in this pod | | `OPENMAIL_MODE` | `channel` (default), `notify`, or `tool` | | `OPENMAIL_ALLOWED_USERS` | Comma-separated senders Hermes lets through | | `OPENMAIL_ALLOW_ALL_USERS` | `true` to leave filtering to OpenMail. Setup writes this | | `OPENMAIL_HOME_ADDRESS` | Where `--deliver openmail` sends | | `OPENMAIL_BASE_URL` | API host. Default `https://api.openmail.sh` | Skipping setup? `OPENMAIL_API_KEY` and `OPENMAIL_ALLOW_ALL_USERS=true` are enough. Scripted: `hermes openmail setup --api-key -y`, or `--api-key-stdin`. ## Related * [OpenClaw](/integrations/openclaw): the same design as a channel plugin * [Sender rules](/concepts/sender-rules): who can email the agent * [Pods](/concepts/pods): grouping inboxes under one key * [Attachments](/concepts/attachments): server-side text extraction # OpenClaw Source: https://docs.openmail.sh/integrations/openclaw Give your OpenClaw agent an email address. Install [`@openmail/openclaw`](https://github.com/openmailsh/openclaw-plugin) and mail to the agent's address wakes it. Replies go out in the same thread. ## Before you start * OpenClaw 2026.9.2 or newer. * An OpenMail API key from the [console](https://console.openmail.sh/api-keys). Any scope works. ## Install ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw plugins install clawhub:@openmail/openclaw ``` Reinstalling? Run `openclaw plugins enable openmail` first; OpenClaw keeps uninstalled plugins disabled. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw channels add --channel openmail --api-key ``` Picks your inbox, or creates one. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw gateway restart openclaw channels status ``` Look for `OpenMail: enabled, configured, running, connected`. Email the agent and it answers in the thread. Or ask it on your usual chat: * "What's in the inbox since this morning?" * "Reply to Marc that Thursday works, and cc Jonas." * "Sign up for the Linear newsletter and give me the confirmation code." Something off? `openclaw logs | grep openmail`. ## Modes | Mode | When mail arrives | Good for | | --------- | ------------------------------------------------------------- | ---------------------------------------- | | `channel` | The agent replies in the thread. | An inbox that *is* the agent: `support@` | | `notify` | The agent tells you on your usual chat and does nothing else. | Your own inbox | | `tool` | Nothing. The agent reads the inbox when you ask. | "Sign up for X and tell me the code" | Default is `channel`. Change it with `--mode notify` on `channels add`, or [per inbox](#several-inboxes). Only mail from a person gets a reply. Notifications and verification codes reach the agent as information, so it never answers `noreply@`. Spam never reaches it. ## Who can email the agent Anyone, by default. Restrict it under **Allow/Block List** in the [console](https://console.openmail.sh/sender-rules) or with the CLI: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openmail policy mode --direction inbound --mode allowlist --inbox-id openmail policy allow --direction inbound --value marc@example.com --inbox-id ``` Blocked mail is dropped on the server; the agent never sees it. ### Prompt injection Replies go to the sender OpenMail recorded, not one the message claims. The bundled CLI takes credentials from the channel config and rejects `--api-key` and `--base-url`, so a forged mail can't point the agent at another account. The agent can still start new threads; to stop that, make the inbox's outbound policy an allowlist. ## What the agent can do The plugin bundles the [CLI](/integrations/cli) and a skill that teaches the agent to use it. Every command runs with the channel's credentials, on the gateway host even if the agent is sandboxed. Keep the `--`: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw openmail -- threads get --thread-id --json openclaw openmail --account sales -- send --to a@b.com --body "..." ``` New threads also work through `openclaw message send --channel openmail --to a@b.com "…"`; the first line becomes the subject. ## Several inboxes **One account per inbox.** Each has its own key and can be bound to a different agent. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw channels add --channel openmail --account support --api-key --mailbox-name support openclaw channels add --channel openmail --account sales --api-key --mailbox-name sales ``` **One account per pod.** Covers every inbox in the [pod](/concepts/pods), including ones the agent creates later. Needs a pod or account key. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw channels add --channel openmail --account outreach --api-key --pod outreach ``` Each sender gets a conversation per inbox; replies leave from the inbox that received the mail. Set modes per inbox under `channels.openmail.inboxes` in `openclaw.json`: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} "inboxes": { "support@omail.sh": { "mode": "channel" }, "me@omail.sh": { "mode": "notify" } } ``` ## Attachments Text from PDF, DOCX, XLSX, PPTX, CSV, and images (OCR) reaches the agent inline, up to 8k characters per file and 24k total. Files without text arrive as files, so a vision model sees the picture; `mediaMaxMb` caps the total per email (default 20). ## Configuration reference `channels add` writes `channels.openmail` in `openclaw.json`. If you gave it an account key, it stores a narrower pod key instead. No environment variables. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "channels": { "openmail": { "apiKey": "om_…", "inboxId": "…", "mode": "channel", "accounts": { "sales": { "apiKey": { "source": "env", "id": "OPENMAIL_SALES_KEY" }, "inboxId": "…" } } } } } ``` | Field | Meaning | | ------------ | ---------------------------------------------------------------------- | | `apiKey` | Inbox key, or pod key for a pod account. Accepts an OpenClaw SecretRef | | `inboxId` | Inbox this account serves | | `podId` | Pod this account serves. Replaces `inboxId` | | `mode` | `channel` (default), `notify`, or `tool` | | `mediaMaxMb` | Staged attachment cap per email. Default `20`; `0` disables | | `inboxes` | Per-inbox `mode` overrides, pod accounts only | | `accounts` | Named accounts with the fields above. Select with `--account` | `channels add` flags: `--mailbox-name sales` creates `sales@omail.sh`, `--display-name` sets the sender name, `--inbox-id` picks an inbox when the key sees several, `--pod` runs a whole pod, `--account` names the account, `--mode` sets the mode. ## Upgrading from the CLI bridge Before `@openmail/cli` 0.7.0, `openmail setup` ran a WebSocket bridge as a system service. Those commands are gone. ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} # Linux systemctl --user disable --now openmail-openclaw-bridge.service # macOS launchctl unload -w ~/Library/LaunchAgents/sh.openmail.openclaw-bridge.plist ``` Follow [Install](#install) with the key from `~/.openclaw/openmail.env`. Delete `~/.openclaw/openmail.env`, `~/.openclaw/skills/openmail/`, and the `skills.entries.openmail` and OpenMail `hooks.mappings` entries in `openclaw.json`. ## Related * [Hermes Agent](/integrations/hermes): the same design as a platform plugin * [Sender rules](/concepts/sender-rules): who can email the agent * [Pods](/concepts/pods): grouping inboxes under one key * [Attachments](/concepts/attachments): server-side text extraction # Frequently Asked Questions Source: https://docs.openmail.sh/pages/resources/faq Frequently asked questions about OpenMail — inboxes, pricing, custom domains, sending limits, authentication, and agent integrations answered. ## How do I get started? For OpenClaw, run `openclaw plugins install clawhub:@openmail/openclaw` then `openclaw channels add --channel openmail --api-key `. For Hermes, run `hermes plugins install openmailsh/hermes-plugin --enable` then `hermes openmail setup`. For other agents, install the CLI with `npm install -g @openmail/cli` and run `openmail init` to create your first inbox. See the [Quickstart](/quickstart) for the full walkthrough. ## How does pricing work? You get 3 inboxes, 3,000 emails/month, and 3 GB storage free forever. No credit card required. Pro is €9/month and includes 10 inboxes, 10,000 emails, and 10 GB storage — past that you pay only for what you use: €1 per extra inbox, €0.001 per extra email, €0.10 per GB. Custom domains are unlimited on Pro. Enterprise is custom pricing for dedicated IPs, SSO, and volume discounts. Cancel anytime. [See full pricing](https://openmail.sh/pricing). ## Can I set a spending limit? Yes, on Pro. A spending limit is enabled by default, with an email at 80% and 100% of it — just go to Billing in the [Dashboard](https://console.openmail.sh/settings/billing) to change the amount (or turn it off). Auto-pause, which stops sending, inbox creation, and domain creation for the rest of the billing cycle once you hit your limit, stays off by default so nothing is ever blocked unless you turn it on. ## How do I get an API key? Sign up at [console.openmail.sh](https://console.openmail.sh/login). Complete the setup wizard and your API key is ready immediately. See [Quickstart](/quickstart) for the full setup flow. ## How do I receive inbound email? Via WebSocket or webhook. WebSocket is recommended for agents — no public URL needed, instant delivery. Connect to `wss://api.openmail.sh/v1/ws` with your API key. For webhooks, set your URL in the [Dashboard](https://console.openmail.sh/login). See [WebSockets](/concepts/websockets) and [Webhooks](/guides/webhooks) for full setup. ## Can an agent have multiple inboxes? Yes. Create as many inboxes as you need. Each event includes `inbox_id`, so you can map it to the correct user or agent in your app. See [Inboxes](/concepts/inboxes). ## Can I create many inboxes? Yes. One account, many inboxes. No separate tenant abstraction - create inboxes and route by `inbox_id`. ## How do webhooks work? Configure a webhook URL in the [Dashboard](https://console.openmail.sh/login). When inbound email arrives, we POST a `message.received` event. See [Overview](/concepts/webhooks) and [Setup](/guides/webhooks). ## What's the Idempotency-Key for? It makes retries safe. The header is optional — without it OpenMail generates a key and sends once — but if your code retries on timeouts, pass a unique UUID per send so a retry never creates a duplicate email. See [Idempotency](/best-practices/idempotency). ## Can I control who my inboxes talk to? Yes, in both directions. Sender rules block specific addresses or entire domains, and allowlist mode limits an inbox to approved contacts. Inbound and outbound are set independently, at the account, pod, or inbox scope. Manage rules from **Allow/Block List** in the [Dashboard](https://console.openmail.sh/login); changes are owner-only, and every change and refusal shows up under **History**. See [Sender rules](/concepts/sender-rules). ## How do I handle attachments? Inbound attachments include a signed URL in the payload. Fetch promptly; URLs expire. See [Attachments](/concepts/attachments). # SPF, DKIM, DMARC Source: https://docs.openmail.sh/pages/resources/security/email-protocols How OpenMail implements SPF, DKIM, and DMARC to authenticate your emails and maximize inbox delivery rates for outbound agent messages. OpenMail handles SPF and DKIM for your sending domain. Add a domain in the [Dashboard](https://console.openmail.sh/login) or via the [API](/concepts/custom-domains) and publish the DNS records we return. The domain verifies automatically once they are live. ## SPF SPF (Sender Policy Framework) authorizes our servers to send on behalf of your domain. Add the TXT record we provide on the `bounce` host (your custom MAIL FROM subdomain). ## DKIM DKIM (DomainKeys Identified Mail) signs outgoing messages. Publish the three CNAME records we provide under `_domainkey` (hosts like `omail._domainkey`). ## DMARC DMARC (Domain-based Message Authentication) tells receivers what to do with messages that fail SPF/DKIM. The DNS panel includes a default record — `v=DMARC1; p=none;` at `_dmarc` — which satisfies Google and Yahoo's bulk sender requirements and has no effect on delivery. DMARC is your own published policy, so it is not part of domain verification: a domain verifies without it. Once SPF and DKIM are aligned and you have reviewed your reports, tighten the policy to `p=quarantine` or `p=reject`. If your domain already publishes a `_dmarc` record, keep it rather than replacing it. # Support Source: https://docs.openmail.sh/pages/resources/support Get help with OpenMail via Discord, email support, or GitHub issues. Find answers to common questions, report bugs, and request new features. Need help? Reach out: * **Email:** [support@openmail.to](mailto:support@openmail.to) * **Discord:** [Join our community](https://discord.com/invite/eFfQFMZbsK) * **Dashboard:** [console.openmail.sh](https://console.openmail.sh/login) # Events Source: https://docs.openmail.sh/pages/webhooks/events OpenMail webhook event reference — event types, full payload structure, and field descriptions for inbound email and reputation lifecycle (inbox/pod suspension and reactivation) events. OpenMail sends webhooks when inbound email is received, and when the reputation engine suspends or reactivates one of your inboxes or pods. Emails dropped by [sender rules](/concepts/sender-rules) (a block rule, or allowlist mode with no match) do not trigger events; they are discarded before threading and show up in the Allow/Block List [history](/concepts/sender-rules#history) instead. ## Events | Event | Description | | ------------------- | ----------------------------------------------------------------------------- | | `message.received` | A new inbound email was delivered to an inbox | | `inbox.suspended` | An inbox was suspended (sending blocked) by the reputation engine or an admin | | `inbox.reactivated` | A previously suspended inbox was reactivated | | `pod.suspended` | A pod was suspended — every inbox in it is blocked from sending | | `pod.reactivated` | A previously suspended pod was reactivated | `inbox.*` and `pod.*` are **reputation lifecycle** events: they let you react when one of your end-users is cut off (for example, pause that agent) without your whole account being affected. We handle suspension automatically; these events tell you when it happens. They are delivered to your HTTP webhook only (not over [WebSockets](/concepts/websockets)). ## Use cases * **Agent routing** - Use `inbox_id` to route inbound email to the right user or container. * **Real-time replies** - Trigger your agent to process and reply within seconds of receipt. * **Multi-tenant** - Each inbox maps to an agent; `inbox_id` in the payload tells you which one. ## Payload structure ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "event": "message.received", "event_id": "evt_abc123", "occurred_at": "2024-03-21T10:05:00.000Z", "delivered_at": "2024-03-21T10:05:01.000Z", "attempt": 1, "inbox_id": "inb_92ma...", "thread_id": "thr_xyz...", "message": { "id": "msg_...", "rfc_message_id": "", "from": "sender@example.com", "to": "inbox@omail.sh", "cc": [], "subject": "Email subject", "body_text": "Plain text body", "attachments": [ { "filename": "document.pdf", "contentType": "application/pdf", "sizeBytes": 12345, "url": "https://api.openmail.sh/v1/attachments/msg_.../document.pdf", "parsedText": "Invoice #2847\nDate: March 15, 2026\nAmount: $1,250.00", "extractionMethod": "pdf" } ], "raw_url": "https://api.openmail.sh/v1/messages/msg_.../raw", "received_at": "2024-03-21T10:05:00.000Z" } } ``` ## Field descriptions | Field | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `event` | Event type — e.g. `message.received` (see [Events](#events)) | | `event_id` | Unique ID for this delivery. Use for deduplication - we may retry. | | `occurred_at` | When the event happened. Use to sort if events arrive out of order. | | `inbox_id` | The inbox that received the message | | `thread_id` | Conversation thread. Use with the API to fetch or send messages. | | `message.id` | Unique message ID | | `message.from` | Sender email address | | `message.subject` | Email subject line | | `message.body_text` | Plain-text body | | `message.attachments` | Array of attachments with `url`, `parsedText` (extracted content), and `extractionMethod`. See [Attachments](/concepts/attachments). | | `message.raw_url` | Link to the original message as `message/rfc822`. Fetch with your API key. `null` when not available. | ## Reputation lifecycle payload `inbox.suspended`, `inbox.reactivated`, `pod.suspended`, and `pod.reactivated` share the structure below. They carry a `reason` instead of a `message`. For `pod.*` events, `inbox_id` and `inbox_address` are `null`. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "event": "inbox.suspended", "event_id": "evt_def456", "occurred_at": "2024-03-21T10:05:00.000Z", "inbox_id": "inb_92ma...", "inbox_address": "agent@yourdomain.com", "pod_id": "pod_abc...", "reason": "high_bounce_rate" } ``` | Field | Description | | --------------- | ------------------------------------------------------------------ | | `inbox_id` | The affected inbox, or `null` for `pod.*` events | | `inbox_address` | The affected inbox's email address, or `null` for `pod.*` events | | `pod_id` | The affected pod (or the suspended inbox's pod), if any | | `reason` | Why it was suspended (see below); `null` on `*.reactivated` events | ### Reason codes | Code | Meaning | | --------------------- | --------------------------------------------- | | `high_bounce_rate` | Too many of the inbox's recipients bounced | | `high_complaint_rate` | Too many recipients marked messages as spam | | `abuse` | Phishing or abusive sending was detected | | `pod_degraded` | The pod's overall sending health fell too far | | `manual_review` | Suspended by OpenMail staff pending review | | `other` | Suspended for another reason | A suspended inbox or pod is blocked from sending until reactivated. The reputation engine never auto-recovers a suspension — reactivation is an explicit action, which is when the `*.reactivated` event fires. ## Headers | Header | Description | | -------------- | ------------------------------------------ | | `Content-Type` | `application/json` | | `X-Timestamp` | Unix timestamp (seconds) used in signature | | `X-Signature` | HMAC-SHA256 signature | | `X-Event-Id` | Unique event ID for deduplication | HMAC signature verification. Full implementation guide. # Verification Source: https://docs.openmail.sh/pages/webhooks/verification Verify OpenMail webhook payloads using HMAC-SHA256 signatures. Step-by-step examples for checking signatures and preventing replay attacks. Always verify the webhook signature before processing. Never trust unverified payloads. ## Why verify? Without verification, anyone who discovers your webhook URL could send fake requests, potentially triggering actions on spoofed events or exhausting your resources. Always verify in production. ## Signature format ``` HMAC-SHA256(webhook_secret, "{timestamp}.{raw_json_payload}") ``` * `timestamp` = value of `X-Timestamp` header * `raw_json_payload` = raw request body as string (do not parse JSON first) ## Verification steps 1. Read the raw request body as a string. 2. Get `X-Timestamp` and `X-Signature` from headers. 3. Compute `HMAC-SHA256(secret, timestamp + "." + body)`. 4. Compare with `X-Signature` using **constant-time comparison** (e.g. `crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python). 5. Verify `X-Timestamp` is within 5 minutes of current time (replay protection). Use constant-time comparison to prevent timing attacks. Do not use `==` or string equality. ## Example (Node.js) ```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}} const crypto = require("crypto"); function verifyWebhook(payload, timestamp, signature, secret) { const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${payload}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") ); } ``` ## Example (Python) ```python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import hmac import hashlib def verify_webhook(payload: bytes, timestamp: str, signature: str, secret: str) -> bool: message = f"{timestamp}.{payload.decode()}".encode() expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected) ``` ## Getting your secret Your webhook secret is shown in the [Dashboard](https://console.openmail.sh/login) when you configure your webhook URL. Store it in an environment variable (e.g. `OPENMAIL_WEBHOOK_SECRET`) and never commit it to version control. ## Troubleshooting | Issue | Solution | | ---------------------------- | --------------------------------------------------------------------------------------------- | | Signature verification fails | Use the raw request body - do not parse JSON first. Ensure your secret matches the dashboard. | | Wrong secret | Rotate the secret in **Settings** → **Webhook secret** → **Rotate** and update your env. | | Timestamp expired | Verify `X-Timestamp` is within 5 minutes of current time. Check server clock sync. | | Body parsing strips data | In Express, use `express.raw()` for the webhook route, not `express.json()`. | See [Setup](/guides/webhooks) for full server examples and local development. # Protocol reference Source: https://docs.openmail.sh/pages/websockets/protocol OpenMail WebSocket protocol reference — message types, subscribe payloads, event replay options, and connection lifecycle management. After [connecting](/concepts/websockets#connecting), communication happens via JSON messages. This page documents every message type, subscription behavior, and connection lifecycle detail. ## Subscribe Send a `subscribe` message to start receiving events. You can filter by inbox, event type, or both. ```json Subscribe to all inboxes and all events theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribe" } ``` ```json Subscribe to specific inboxes theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribe", "inbox_ids": ["inb_8f3a1b2c", "inb_2d4e6f8a"] } ``` ```json Subscribe to specific event types theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribe", "inbox_ids": ["inb_8f3a1b2c"], "event_types": ["message.received"] } ``` The server responds with a `subscribed` confirmation that echoes back your active subscriptions: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribed", "inbox_ids": [], "event_types": [] } ``` When `inbox_ids` is empty in the response, you are subscribed to all inboxes on your account. When `event_types` is empty, you receive all event types. Otherwise, the arrays list your specific filters. Subscriptions accumulate — sending multiple `subscribe` messages adds to your existing subscriptions. When subscribing to specific `inbox_ids`, OpenMail verifies you own each inbox. Unowned inbox IDs return an error. *** ## Unsubscribe Remove subscriptions without disconnecting. ```json Unsubscribe from specific inboxes theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "unsubscribe", "inbox_ids": ["inb_8f3a1b2c"] } ``` ```json Unsubscribe from everything theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "unsubscribe" } ``` After unsubscribing from everything, you stop receiving events until you send a new `subscribe` message. *** ## Event replay with `last_event_id` If your client disconnects and reconnects, pass `last_event_id` in the subscribe message to resume from where you left off: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "subscribe", "last_event_id": "evt_7f2a3b4c" } ``` OpenMail replays all events that occurred after that event ID (up to 100), filtered by your subscription. This prevents data loss during brief disconnections. Track the `event_id` of each event you process. On reconnect, pass the last one you successfully handled. If the ID is not found or doesn't belong to your account, you'll receive an error. *** ## Receiving events Events arrive as JSON messages on the WebSocket. The payload is identical to the [webhook event payload](/pages/webhooks/events). ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "event": "message.received", "event_id": "evt_7f2a3b4c", "occurred_at": "2026-02-24T10:05:00.000Z", "delivered_at": "2026-02-24T10:05:00.012Z", "attempt": 1, "inbox_id": "inb_8f3a1b2c", "thread_id": "thr_9d4e5f6a", "message": { "id": "msg_4c8d5e6f", "rfc_message_id": "", "from": "customer@example.com", "to": "jane@omail.sh", "cc": [], "subject": "Re: Your order", "body_text": "Thanks for following up...", "attachments": [], "received_at": "2026-02-24T10:05:00.000Z" } } ``` See [Events](/pages/webhooks/events) for full field descriptions. *** ## Ping / pong Send an application-level `ping` to check the connection is alive: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "ping" } ``` The server responds with: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "type": "pong" } ``` This is separate from the WebSocket protocol-level pings the server sends for heartbeat (see [Connection management](#connection-management) below). *** ## Message reference ### Client → Server | Message | Fields | Description | | ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `subscribe` | `inbox_ids?`, `event_types?`, `last_event_id?` | Subscribe to events. Omit filters to get all events for all inboxes. Pass `last_event_id` to replay missed events. | | `unsubscribe` | `inbox_ids?` | Remove subscriptions. Empty = unsubscribe from all. | | `ping` | | Application-level keepalive. | ### Server → Client | Message | Fields | Description | | -------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `subscribed` | `inbox_ids`, `event_types` | Subscription confirmed. Empty arrays = all inboxes / all event types. | | `unsubscribed` | `inbox_ids` | Unsubscription confirmed. | | `error` | `message` | Error details (invalid JSON, unknown type, unauthorized inbox, rate limit, revoked key). | | `pong` | | Response to `ping`. | | *(event)* | Same as [webhook payload](/pages/webhooks/events) | Email event pushed in real-time. | *** ## Connection management ### Heartbeat The server sends WebSocket protocol-level pings every 30 seconds. Connections that don't respond within 10 seconds are terminated. ### Reconnection Implement exponential backoff on the client. Start at 1 second, cap at 30 seconds. Reset the counter on successful connection. Use `last_event_id` to resume without data loss. The Python `websockets` library handles reconnection automatically with `async for ws in connect(...)`. ### Connection limits You can open up to **10 WebSocket connections** per account. Events are delivered to all connections whose subscriptions match. Exceeding the limit returns an error and closes the new connection. ### Rate limiting Each connection is limited to **30 messages per 10-second window**. Exceeding the limit closes the connection with an error. ### Re-authentication The server periodically verifies your API key is still valid. If your key is revoked or rotated, existing connections are closed with an error. *** ## Error codes | Close code | Meaning | | ---------- | ----------------------------------------- | | `1001` | Server shutting down (reconnect) | | `4001` | Unauthorized (invalid or revoked API key) | | `4008` | Connection limit exceeded | | `4029` | Rate limit exceeded | Why WebSockets, connecting, delivery semantics. Connect and receive events in under 10 lines. Event payload structure (shared with WebSocket). # Quickstart Source: https://docs.openmail.sh/quickstart Get your AI agent its own email address. Pick your agent framework and follow its setup guide to create an inbox and start sending and receiving email in minutes. Get your AI agent its own email address. ## Prerequisites * [OpenMail account](https://console.openmail.sh/login) (free, no credit card) * An API key from the [console](https://console.openmail.sh/api-keys) ## Pick your agent Each guide installs the integration, creates an inbox, and sends a first email. Install the plugin and enable your agent to send and read emails. Plugin with guided setup. Three command lines and OpenMail is live. Install the CLI and the `openmail` skill. Also works for Cursor and Codex. Every `openmail` command, for agents and scripts that work from a shell. Create inboxes and send email programmatically in any language. ## Narrow the agent's key An account-wide key reaches every inbox. Give the agent a scoped key instead, so a compromised agent can reach nothing beyond its own inbox or pod. The OpenClaw and Hermes plugins narrow the key during setup; for the CLI and API paths, mint one yourself. See [Agent keys](/best-practices/api-key-scopes). ## Next steps Route replies to your agent over WebSockets or [webhooks](/guides/webhooks). Warm-up schedules and content best practices. How threads work across inbound and outbound. Every endpoint, for programmatic workflows.