# 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. # Change custom domain pod scope Source: https://docs.openmail.sh/api-reference/domains/change-custom-domain-pod-scope https://api.openmail.sh/openapi.json patch /v1/domains/{id} Change which pods can create inboxes on a custom domain. Pass `podId: null` to make it account-wide (always allowed), or a pod id to scope it to that pod — allowed only while every inbox on the domain already lives in that pod, since inboxes cannot move between pods. DNS records and verification are untouched. Requires an account-wide API key. # 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` > [card] [API Overview](/api-reference/overview) > > Base URL, error format, and common error codes > [card] [Authentication](/api-reference/authentication) > > Bearer token and API key setup > [card] [Inboxes](/api-reference/overview) > > Create, list, get, and delete inboxes > [card] [Custom domains](/concepts/custom-domains) > > Send and receive from your own domain ## Quick reference **Authentication** `string` `Authorization: Bearer om_...` **Idempotency** `string` Send endpoint accepts an optional `Idempotency-Key` header (UUID) that makes retries safe **Webhooks** `string` See [Setup](/guides/webhooks) for payload and signature verification **Rate limits** `string` 100 inbox creations/day; 10 sends/min and 200/day per inbox # Get cold outreach limits Source: https://docs.openmail.sh/api-reference/limits/get-cold-outreach-limits https://api.openmail.sh/openapi.json get /v1/limits/cold Today's caps on emailing **new recipients** — people who have never written to the inbox — and how much of each you have used. Returns the account cap, every verified custom domain's warm-up tier and what unlocks the next one, and a `nextStep` sentence you can act on. Replies and follow-ups never count. Check this before a batch instead of discovering the cap through a 429. An inbox-scoped key sees the account cap plus its own inbox and domain. An account-wide key sees everything; pass `inboxId` to include that inbox's per-inbox cap and only its domain. # 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), `bcc`, 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` recipients are delivered and stored on the outbound message, but they are not listed on the copy To and Cc recipients receive. An address already in `to` or `cc` is not also blind-copied. # 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 | | 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`, `bcc`, 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. # Add owned domain Source: https://docs.openmail.sh/api-reference/owned-domains/add-owned-domain https://api.openmail.sh/openapi.json post /v1/owned-domains Register a domain your organisation controls but does not send from through OpenMail — your company's main domain, say, while your agents send from a subdomain. Once verified, recipients on it (and on any subdomain) do not count as new recipients for the cold outreach limits, so agents can write to your own staff freely. The response includes the one TXT record to publish; then call verify. Verified custom domains are covered automatically and do not need adding here. Account-wide key only. # Delete owned domain Source: https://docs.openmail.sh/api-reference/owned-domains/delete-owned-domain https://api.openmail.sh/openapi.json delete /v1/owned-domains/{id} Remove an owned domain. Recipients on it count as new recipients again from the next send. This does not remove the TXT record from your DNS. # Get owned domain Source: https://docs.openmail.sh/api-reference/owned-domains/get-owned-domain https://api.openmail.sh/openapi.json get /v1/owned-domains/{id} Fetch one owned domain, including the TXT record to publish and its current status. # List owned domains Source: https://docs.openmail.sh/api-reference/owned-domains/list-owned-domains https://api.openmail.sh/openapi.json get /v1/owned-domains List the domains your organisation has registered for ownership proof, verified or not. # Verify owned domain Source: https://docs.openmail.sh/api-reference/owned-domains/verify-owned-domain https://api.openmail.sh/openapi.json post /v1/owned-domains/{id}/verify Look up the TXT record now and update `status`. Call this after publishing the record. A resolver failure leaves the status unchanged rather than marking the domain failed. # 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. `inbound_rejected_no_inbox` records mail a verified custom domain accepted for an address with no inbox: it is dropped after SMTP, so the sender gets no bounce and this row is the only trace the domain's owner can reach. It is deduped per address per hour and capped per organisation per hour. 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. # Create webhook endpoint Source: https://docs.openmail.sh/api-reference/webhooks/create-webhook-endpoint https://api.openmail.sh/openapi.json post /v1/webhooks/endpoints Add an endpoint that receives events. The `secret` you pass decides how requests are authenticated; see the `auth` field. # Delete webhook endpoint Source: https://docs.openmail.sh/api-reference/webhooks/delete-webhook-endpoint https://api.openmail.sh/openapi.json delete /v1/webhooks/endpoints/{id} Delete the endpoint and its delivery history. # Get webhook delivery Source: https://docs.openmail.sh/api-reference/webhooks/get-webhook-delivery https://api.openmail.sh/openapi.json get /v1/webhooks/deliveries/{id} One delivery with its payload and the endpoint URL it went to. # Get webhook endpoint Source: https://docs.openmail.sh/api-reference/webhooks/get-webhook-endpoint https://api.openmail.sh/openapi.json get /v1/webhooks/endpoints/{id} One webhook endpoint, with last-24h delivery stats. The secret is masked. # List event types Source: https://docs.openmail.sh/api-reference/webhooks/list-event-types https://api.openmail.sh/openapi.json get /v1/webhooks/event-types Event types a webhook endpoint can subscribe to. # List webhook deliveries Source: https://docs.openmail.sh/api-reference/webhooks/list-webhook-deliveries https://api.openmail.sh/openapi.json get /v1/webhooks/deliveries Delivery log for your organisation, newest first. # List webhook endpoints Source: https://docs.openmail.sh/api-reference/webhooks/list-webhook-endpoints https://api.openmail.sh/openapi.json get /v1/webhooks/endpoints Every webhook endpoint in your organisation, oldest first, with last-24h delivery stats. # Redeliver webhook Source: https://docs.openmail.sh/api-reference/webhooks/redeliver-webhook https://api.openmail.sh/openapi.json post /v1/webhooks/deliveries/{id}/redeliver Send the same payload to the endpoint again, with retries. The delivery goes back to `pending`. # Rotate webhook secret Source: https://docs.openmail.sh/api-reference/webhooks/rotate-webhook-secret https://api.openmail.sh/openapi.json post /v1/webhooks/endpoints/{id}/rotate-secret Generate a new OpenMail signing secret. The old one stops working immediately. Only for `auth: openmail`; to replace a secret you supplied, update the endpoint with a new `secret`. # Send test event Source: https://docs.openmail.sh/api-reference/webhooks/send-test-event https://api.openmail.sh/openapi.json post /v1/webhooks/endpoints/{id}/test Queue one example event to the endpoint, authenticated like a real one. It is sent once, not retried, and not logged as a delivery. # Update webhook endpoint Source: https://docs.openmail.sh/api-reference/webhooks/update-webhook-endpoint https://api.openmail.sh/openapi.json patch /v1/webhooks/endpoints/{id} Change only the fields you include. Pass `secret` to replace how requests are authenticated: an empty string generates a new OpenMail secret (returned in this response). # 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. > [tab] CLI > > ```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`. > [tab] API > > ```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 How to send so your agents' email lands in the inbox: domain setup, letting the cold cap do the warm-up, and content that gets replies. This page assumes you know what SPF, DKIM and warm-up are. It covers what's different on OpenMail: the cold cap does the warm-up, and reputation is tracked per inbox and per domain. ## Domain **1. Add a custom domain** Reputation built on the shared `omail.sh` isn't yours. Add a [custom domain](/concepts/custom-domains) (Pro and above) and run outreach from there. **2. Publish DMARC too** The domain verifies without it, so it's easy to skip. Gmail and Yahoo require it from bulk senders. Keep an existing `_dmarc` record if you have one. **3. Probe before you write to anyone** Send between two inboxes on the new domain. It doesn't count against the cold cap, and a bounce here means a DNS problem. > [!TIP] > > Keep cold mail on its own subdomain, like `outreach.yourcompany.com`, so a reputation hit never touches the domain your team uses. Several products or clients: one domain each. Each domain ramps on its own, but the account cap still applies, so this buys isolation, not volume. ## Sending Warm-up is built in. A new domain ramps as it ages and as you actually reach people; there's no schedule to follow and no need to hit the cap every day. | Days since domain added | New recipients messaged so far | New recipients/day | | -: | -: | -: | | 0+ | 0+ | 20 | | 7+ | 50+ | 50 | | 14+ | 150+ | 100 | | 28+ | 400+ | your account cap | Three things that slow it down: * **Adding inboxes for volume.** Caps are per domain and per account, so 50 inboxes on a new domain still share 20 new recipients a day. Create inboxes for separate identities, not throughput. * **Bursting.** 10 a minute per inbox is the hard limit, but a domain that sends 50 at 09:00 and nothing until tomorrow looks like a blast. Queue cold sends over the working day. * **Retrying a 429.** The response names the cap and `resetsAt` (midnight UTC). Another inbox on the same domain hits the same cap. See [When you get a 429](/concepts/rate-limits#when-you-get-a-429). > [!WARNING] > > Over 5% bounces in a week throttles the inbox; 10% suspends it, and only a review lifts that. Validate addresses; don't buy lists. ## The first message Filters read the first message to a stranger more harshly than anything after it, and a reply is what grows your cap. Write to earn one. > [card] Do > > * Open with their name and something specific to them > * Ask one question; leave the pitch for the reply > * Keep it short enough to read on a phone without scrolling > * Send the link once they've answered > [card] Don't > > * Open with a sentence that could have gone to anyone > * Use "free", "limited time", capitals or exclamation marks > * Put links or images in the first message > * Add a tracking pixel — it's an image, and trips the same filters Plain text is handled: the API generates a text part for every HTML message and adds no tracking pixel. ## Bounces and complaints An address that bounces, complains or unsubscribes goes on your [suppression list](/concepts/suppressions) and later sends to it return `422 recipient_suppressed`. When your agent sees that, drop the contact or try another channel. Don't look for a way around it; a second bounce to the same address is what costs reputation. ## What's next > [card] [Rate limits](/concepts/rate-limits) > > The exact caps per plan and the reputation thresholds. > [card] [Custom domains](/concepts/custom-domains) > > DNS records, verification and pod scoping. > [card] [Suppressions](/concepts/suppressions) > > The three reasons an address is refused, and the error you get. > [card] [Idempotency](/best-practices/idempotency) > > Safe retries so a `429` never turns into a duplicate send. # 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. ## 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 Open [Domains](https://console.openmail.sh/domains) in the console, enter the domain you want to use (e.g. `mail.acme.com`) and click **Add domain**. You can also add it with `POST /v1/domains`. > [!NOTE] > > 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 The domain page lists every record with a copy button for the host and the value. Each row turns green as the record is found. ![DNS records panel showing five pending rows: MX, DKIM TXT, MAIL FROM MX, SPF TXT and DMARC TXT, each with copy buttons](https://mintcdn.com/openmail/rFGIBsTSNdP9cyZ6/images/custom-domains/dns-records-dark.png?fit=max\&auto=format\&n=rFGIBsTSNdP9cyZ6\&q=85\&s=ca03f4944136ada1646abe2ea020ed69) DNS records for a newly added domain, waiting on DNS | Type | Host | Value | | - | - | - | | MX | `@` (or your subdomain) | `inbound-smtp.eu-north-1.amazonaws.com` (priority 10) | | TXT | `omail._domainkey` (+ your subdomain) | `v=DKIM1; k=rsa; p=…` (unique per domain) | | 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 record** is a single TXT on `omail._domainkey`. It adds a cryptographic signature to outbound email, proving messages were sent by you. The public key is unique per domain and provided after you add it. Copy the whole value; a truncated key fails verification. **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 **Check DNS**. Once the records are published, OpenMail checks them in the background and the domain becomes **Verified** within a couple of minutes. Click **Check DNS** only if you want to check sooner. ![DNS records panel with a green Verified badge and a green check next to each of the five records](https://mintcdn.com/openmail/rFGIBsTSNdP9cyZ6/images/custom-domains/verified-dark.png?fit=max\&auto=format\&n=rFGIBsTSNdP9cyZ6\&q=85\&s=bc532fbb32cdb800fc7231b509040713) All records found — the domain can send and receive > [!NOTE] > > 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` * DKIM value truncated, 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 change scope later, open the domain's page in the [console](https://console.openmail.sh/domains) and click **Change** next to "Available to …" (also under the **⋯** menu), or call `PATCH /v1/domains/{id}` with `podId` (a pod id, or `null` for all pods). From the CLI: `openmail domain update --domain-id --pod-id ` or `--all-pods`. DNS records and verification are untouched. Making a domain account-wide always works. Narrowing it to one pod only works if every inbox on that domain is already in that pod. Inboxes cannot move between pods, so if some live elsewhere the change is refused and the response tells you how many and what to do: delete those inboxes first, or keep the domain available to all pods. Scope changes need an account-wide API key. ## 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. > [!TIP] > > 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. To remove individual conversations instead, see [Deleting mail](/concepts/threading#deleting-mail). # 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 ``` > [!NOTE] > > 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"}' ``` > [!TIP] > > 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 > [accordion] Do I need to use pods? > > 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. > [accordion] Can inboxes in different pods email each other? > > 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. > [accordion] Can I move an inbox from one pod to another? > > 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. > [accordion] How many pods can I create? > > 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). > [accordion] Can I restrict an API key to a single pod? > > 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). > [accordion] Can I restrict an API key to a single inbox? > > 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). > [accordion] Can each pod have its own custom domain? > > 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 a cold-outreach cap on new recipients to protect deliverability. Learn how the limits are counted and how to stay within them. Two kinds of limit apply to every send: * **Send limits** cap how fast an inbox sends, to anyone. * **Cold outreach cap** limits how many **new recipients** you email per UTC day. Starts low and grows as you build history. Replies and follow-ups only meet the first. Cold outreach meets both. ## Send limits | Limit | Value | Scope | | - | - | - | | Burst | 10/minute | Per inbox | | Daily | 200/day | Per inbox | | Inbox creation | 100/day | Per account | Over one of these, 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" } ``` ## Cold outreach cap A recipient is **new** the first time an inbox emails an address that has never written to it. Each To/Cc address counts once; Bcc, your own inboxes, anyone on one of your verified domains (see below), and anyone who has already emailed or been emailed by that inbox don't count. Warmth is per inbox, so a contact known to inbox A is still new to inbox B. ### Your own domains don't count Mail to your own organisation isn't cold outreach, so addresses on your organisation's domains, and on their subdomains, never count as new. Three things make a domain yours: * **A verified sending domain** covers its whole registrable domain. Verify `agents.acme.com` and `acme.com`, `hr.acme.com` and every other `*.acme.com` are covered. Only the registrable domain qualifies: `you.example-hosting.com` on a shared host doesn't cover the rest of that host. * **A team member's login.** If someone on your team signs in with `name@acme.com`, `acme.com` is covered. Public mailbox providers such as `gmail.com` don't count. * **A TXT record** for any other domain you own, such as a second brand your agents don't send from. Add it under [Exempt from the cold cap](https://console.openmail.sh/domains#exempt), publish the one record shown (`_openmail-owner.`), and verify. You can also use `POST /v1/owned-domains`. Up to 20 per organisation. [Exempt from the cold cap](https://console.openmail.sh/domains#exempt) lists every covered domain. Domains covered by a sending domain or a login show as **Inherited**. Open a TXT-verified domain to see its DNS record and the mail your inboxes sent to it. `GET /v1/limits/cold` returns the same list as `internalDomains`. Coverage is re-checked. A sending domain that fails DNS, or a TXT record that disappears, loses the exemption until you fix it. > [tab] Pro > > Three caps apply at once. The lowest one you reach is the one that blocks. > > | Scope | Warm-up (first 7 days or 100 new recipients) | After | > | - | - | - | > | Inbox | 20/day | no cold cap (200/day send limit still applies) | > | Custom domain | tier schedule below | tier schedule below | > | Account | 150/day | 2× your trailing 7-day average, 150 to 500 | > > **Custom domain tiers.** Each domain climbs on its own. Both columns are counted from the day you added the domain to OpenMail; how long you've owned it doesn't matter. Inboxes on the shared `omail.sh` domain have no domain cap. > > | Tier | Days since domain added | New recipients messaged so far | New recipients/day | > | - | - | - | - | > | 0 | 0+ | 0+ | 20 | > | 1 | 7+ | 50+ | 50 | > | 2 | 14+ | 150+ | 100 | > | 3 | 28+ | 400+ | your account cap | > > **To grow your caps** > > 1. Send to new recipients most days. The account cap follows your trailing average, and a domain's tier needs volume as well as age. > 2. Keep bounces under 5% and complaints under 0.1%. Anything worse throttles the inbox, and at 10% bounces or 0.3% complaints suspends it; see [Reputation](#reputation). > 3. Get replies. Cold threads that get a human reply are what let the account cap keep doubling. > > Need volume before the history exists? Contact us about a trusted volume for a vetted use case. > [tab] Free > > Fixed caps on the shared domain. > > | Scope | First 30 days | After 30 days | > | - | - | - | > | Inbox | 20/day | 200/day | > | Account | 30/day | 100/day | > [tab] Enterprise > > Negotiated caps. Contact us to set them. ## Check your limits `GET /v1/limits/cold` returns today's caps before you hit them: the account cap and what you've used, every verified domain's tier and what unlocks the next one, and a `nextStep` sentence your agent can act on. Nothing is counted or changed by calling it. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "mode": "reputation", "resetsAt": "2026-09-30T00:00:00.000Z", "reputationState": "good", "account": { "limit": 150, "usedToday": 37, "remaining": 113, "ceiling": 500, "avgNewPerDay7d": 31.4, "replyRate": 0.168, "sustainPerDayToGrow": 76 }, "domains": [ { "domain": "mail.acme.com", "tier": 2, "limit": 100, "usedToday": 17, "nextTier": { "limit": null, "minAgeDays": 28, "minLifetimeFirstContacts": 400, "daysToGo": 0, "contactsToGo": 61 }, "health": { "state": "good", "volume7d": 1240, "bounceRate7d": 0.008, "complaintRate7d": 0 } } ], "nextStep": "You've emailed 37 of 150 new recipients today. Your 7-day average is 31.4/day; sustain more than 76/day and the cap rises (up to 500/day). ..." } ``` `sustainPerDayToGrow` is the number to plan around. Average more than that many new recipients a day over a week, keep bounces and complaints clean, and the account cap rises. A `nextTier.limit` of `null` means the domain graduates to the account cap. Each domain's `health` rolls up its inboxes over the last 7 days. `state` is the worst inbox's [reputation](#reputation); the rates are weighted by volume. An inbox-scoped key sees its own inbox and domain. Pass `?inboxId=` with an account key to get the same view for one inbox. The API reference lists every field. [Settings → Limits](https://console.openmail.sh/settings/limits) shows the same numbers. Three cards cover new people today, exempt domains and growth. Below them, a table lists every sending domain with its tier, today's use and health. The console numbers tiers 1 to 4; the API's `tier` is 0 to 3. ## When you get a 429 ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "error": "cold_outreach_limit", "message": "This sending domain has reached today's limit of new recipients. The limit rises as the domain ages and builds a clean sending history. Try again after midnight UTC.", "scope": "domain", "limit": 20, "resetsAt": "2026-09-30T00:00:00.000Z", "nextStep": "This domain is on a warm-up tier of 20 new recipients/day. It moves to 50/day once it is 7+ days old and has reached 50+ first contacts. Check GET /v1/limits/cold for where it stands. Mail to acme.com counted as new. If that is your organisation's domain, verify it at https://console.openmail.sh/domains and mail to it stops counting." } ``` **1. Read the scope** `inbox`, `domain` or `customer` (your account). It tells you which cap you hit, so you know whether another inbox or domain would help. On a `domain` block, another inbox on the same domain won't. **2. Pause cold sends until resetsAt** Caps reset at midnight UTC. Retrying earlier fails the same way. Call `GET /v1/limits/cold` to see what's left on your other inboxes and domains, and `nextStep` for what raises this cap. **3. Keep replying** The rejected send didn't go out and didn't count. Replies and follow-ups to existing contacts are unaffected, so your agents can keep working threads. ## Reputation Every inbox has a reputation state: `good`, `watch`, `throttled` or `suspended`. It's computed from the inbox's permanent bounces and spam complaints over the trailing 7 days. Keep bounces under 5% and complaints under 0.1% and the inbox stays in `good`, with full caps. ### Thresholds | Signal | `watch` | `throttled` | `suspended` | | - | - | - | - | | Bounce rate | Any bounce | 5% | 10% | | Complaint rate | Any complaint | 0.1% | 0.3% | ### What each state does | State | Effect | How it clears | | - | - | - | | `watch` | Caps stop growing; a custom domain drops one tier | On its own, as the rate falls | | `throttled` | Daily send limit cut to 25%; cold caps halved | On its own, as the rate falls | | `suspended` | Every send from the inbox refused | After review by OpenMail; the `inbox.reactivated` [webhook](/pages/webhooks/events) fires | ### If an inbox is suspended 1. Fix what caused the bounces or complaints: validate the list, check DNS, drop the source. 2. Contact us for review. 3. Wait for the `inbox.reactivated` webhook before sending again. For how to send so you never meet these limits, read [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. > [!WARNING] > > 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. > [!NOTE] > > 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 | | `bcc` | Yes. A blind copy is still a delivery, so it is checked like `cc` | | `replyTo` | Yes, otherwise a reply could be routed to an address outside the approved set | 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 `bcc` recipients get the message but are left off the copy that `to` and `cc` recipients receive. Use it for CRM logging addresses (HubSpot, Salesforce) that only record mail sent to them in Bcc. Bcc is not a way around the rules. Every `bcc` address is checked against the outbound policy, counted toward billing and daily volume caps, and stored on the outbound message so you can see who was copied. An address already in `to` or `cc` is not blind-copied a second time. ## 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. Trying to delete it from a child scope is refused, and the response tells you which scope to delete it from. ### 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. > [!TIP] > > 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. ## One-click unsubscribe One-click unsubscribe gives the recipient a way to opt out. OpenMail adds `List-Unsubscribe` and `List-Unsubscribe-Post` headers (RFC 8058), so Gmail, Yahoo and Apple Mail show their own **Unsubscribe** button. Gmail and Yahoo require these headers from bulk senders. When the recipient unsubscribes, they become an `unsub` suppression for your whole organisation, not just the inbox that sent. You don't need to handle the opt-out yourself. Later sends to them return `422 recipient_suppressed`. ### On the shared domains Inboxes on the shared `@omail.sh` and `@openmail.sh` domains get one-click unsubscribe automatically. OpenMail adds it to a send when all of these are true: * It has one recipient, with no `cc` or `bcc`. * The recipient isn't another OpenMail inbox. * The recipient hasn't written to you on the thread yet. You can't turn it off, and you can't set your own `List-Unsubscribe` header. Every organisation on the shared domains shares their reputation. ### On a custom domain On a [custom domain](/concepts/custom-domains), pass `"unsubscribe": true` on a [send](/api-reference/messages/send-email) to add it: ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "to": "lead@example.com", "subject": "Quick question", "body": "Hi Dana, ...\n\nNot interested? Unsubscribe: {{unsubscribe_url}}", "unsubscribe": true } ``` To use your own unsubscribe system instead, set the headers yourself with `headers`. OpenMail never sees those opt-outs, so honouring them is up to you. You can't combine your own `List-Unsubscribe` with `"unsubscribe": true`. ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} { "to": "lead@example.com", "subject": "Quick question", "body": "Hi Dana, ...", "headers": { "List-Unsubscribe": "", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" } } ``` ### Rules * Put `{{unsubscribe_url}}` in the body to show the unsubscribe link in the message. * A body with `{{unsubscribe_url}}` is rejected when the send carries no unsubscribe link, so the placeholder never reaches a recipient unfilled. * The link opts out the `to` address only, so you can't combine `"unsubscribe": true` with `cc` or `bcc`. * An unsubscribed recipient stays blocked even if they later write to you, so your agent can't reply to them. If they ask to hear from you again, email [support@openmail.sh](mailto:support@openmail.sh) to lift the suppression. Suppressions are one part of deliverability. For domain setup, warm-up and content, 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. ## 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 **1. Poll for unread threads** 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 ``` **2. Process the thread** 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 ``` **3. Mark as read** 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. > [!TIP] > > 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. ## Deleting mail Deleting works the way it does in a mail client, in two steps. `DELETE /v1/threads/:id` moves the thread to **Trash**. The API stops returning the thread and its messages, and a reply that arrives later starts a new thread rather than reopening the old one. In the console the thread sits under **Trash**, where you can restore it or delete it for good. Trashed mail still counts toward your storage; anything left in Trash is deleted automatically after 30 days. Deleting from Trash, or that 30-day expiry, removes the thread from every folder and frees its storage at once. The data is permanently erased within a further 30 days. `DELETE /v1/messages/:id` removes a single message and skips Trash. Both endpoints need an account-wide or pod-scoped key. Inbox-scoped keys can read and send only, so they get a `403`. # Overview Source: https://docs.openmail.sh/concepts/webhooks OpenMail webhooks deliver inbound email and reputation events to your endpoints in real time. Learn about endpoints, scoping, authentication, delivery logs, and retries. When an email arrives at one of your inboxes, or the reputation engine suspends or reactivates an inbox or pod, OpenMail sends the event to your endpoints as an HTTP `POST`. See [Events](/pages/webhooks/events) for the full list. ## Why use webhooks? Instead of constantly polling the API to check for new emails, register a URL and OpenMail sends a `POST` as soon as an event happens. * **Real-time** - Build agents that reply to emails in seconds. * **Efficient** - No polling; saves compute and simplifies your logic. ## Endpoints You manage webhooks on the [Webhooks](https://console.openmail.sh/webhooks) page. You can add as many endpoints as you need. Each endpoint has its own: * **URL** - Where OpenMail sends the `POST`. * **Events** - The [event types](/pages/webhooks/events) it receives. * **Scope** - The whole organisation, or a set of pods and inboxes. * **Authentication** - How OpenMail proves the request is genuine. See [Authentication](#authentication). * **Status** - Enabled or disabled. An event goes to every enabled endpoint that subscribes to its type and matches its scope. Each endpoint gets its own delivery and its own retries. Only the organisation owner can add, edit, or delete endpoints, reveal or rotate secrets, send test events, or redeliver. ### Scope Leave **Scope** empty and the endpoint receives events from every inbox and pod. Pick pods or inboxes to narrow it: * An inbox event matches when its inbox is listed, or the inbox's pod is listed. * A `pod.*` event matches when its pod is listed. An endpoint scoped only to inboxes does not receive `pod.*` events. Inbox webhooks set through the API (`webhookUrl` on an inbox) show up here as an endpoint scoped to that inbox. They fire in addition to your organisation-wide endpoints. ### Disabling an endpoint Disable an endpoint from its page to pause it. OpenMail stops sending to it, and pending retries are marked as failed. Events that happen while it is disabled are not kept for later. Enable it again to resume. ## Authentication Each endpoint has one optional **Auth header or secret** field. OpenMail picks the scheme from what you paste: | You paste | OpenMail sends | | - | - | | Nothing | An OpenMail signature (`X-Signature`) made with a secret OpenMail generates | | A `whsec_` secret | [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-signature`) signed with your secret | | Anything else | `Authorization: Bearer `, unsigned | You can paste a whole `Authorization: Bearer …` header or just the token. See [Verification](/pages/webhooks/verification) for the headers and code for each scheme. ## 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. * **Same ID on every endpoint** - When an event goes to several endpoints, each gets the same `event_id`. ## Retry policy A delivery fails when your endpoint doesn't return `2xx` within 15 seconds. OpenMail doesn't follow redirects, so a `3xx` also counts as a failure. Failed deliveries are retried: | Attempt | Delay | | - | - | | 1 | Immediate | | 2 | 30 seconds | | 3 | 60 seconds | | 4 | 2 minutes | | 5 | 4 minutes | After 5 failed attempts, the delivery is marked as failed. You can [redeliver](#delivery-logs) it from the console. ## Delivery logs The **Logs** tab on the [Webhooks](https://console.openmail.sh/webhooks) page lists every delivery. Filter by status (delivered, failed, pending) or event type. Open a delivery to see: * The payload OpenMail sent. * The HTTP response code or error. * How many attempts it took. Click **Replay** to redeliver it. A redelivery sends the same payload with the same `event_id`, with a fresh set of retries. The **Endpoints** tab shows each endpoint's error rate over the last 24 hours. An endpoint's own page shows its delivered and failed counts for the same window, and its message attempts. ## Full payload and verification > [card] [Events](/pages/webhooks/events) > > Payload structure and field descriptions. > [card] [Verification](/pages/webhooks/verification) > > Verify each authentication scheme, with code. > [card] [Setup](/guides/webhooks) > > Add an endpoint and implement the handler. > [card] [Connect a platform](/pages/webhooks/platforms) > > Send events to Grok, Hermes Agent, or OpenClaw. # 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 WebSockets and webhooks are independent. A `message.received` event goes to every connected, subscribed WebSocket and to every matching [webhook endpoint](/concepts/webhooks#endpoints). If you use both, you receive each event twice; deduplicate with `event_id`. | Scenario | Delivery method | | - | - | | WebSocket connected + subscribed | WebSocket (instant) | | Matching webhook endpoint | Webhook (with retries) | | Both | Both, with the same `event_id` | 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. > [card] [Quickstart](/guides/websockets/quickstart) > > Connect and receive events in under 10 lines. > [card] [Protocol reference](/pages/websockets/protocol) > > Message types, subscribe options, event replay, connection management. > [card] [Webhook events](/pages/webhooks/events) > > Event payload structure (shared with WebSocket). > [card] [Webhook setup](/guides/webhooks) > > 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)) Use this pre-built prompt to get started faster. 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 ```` > [!TIP] > > **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" } } ``` > [tab] WebSocket (recommended) > > 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"] } > ``` > [tab] Webhook > > Add an endpoint on the [Webhooks](https://console.openmail.sh/webhooks) page. OpenMail POSTs events to it 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 > ``` > > > [!WARNING] > > > > 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 add a webhook endpoint in the console | > [card] [Email deliverability](/best-practices/email-deliverability) > > Warm-up schedules, inbox distribution, and content best practices to stay out of spam. > [card] [WebSockets](/concepts/websockets) > > Real-time event streaming, subscribe protocol, reconnection. > [card] [Webhooks](/guides/webhooks) > > Signature verification, payload format, retries. > [card] [API reference](/api-reference/introduction) > > 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`. Use this pre-built prompt to get started faster. 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, ... } ``` > [!TIP] > > `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. > [!WARNING] > > 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. > [!TIP] > > 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 }); ``` > [!NOTE] > > 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 | > [card] [Pods](/concepts/pods) > > The full pods model: isolation, `clientId`, patterns, and lifecycle. > [card] [Authentication](/api-reference/authentication) > > Account-wide, pod-scoped, and inbox-scoped keys, and the token-once rule. > [card] [Custom domains](/concepts/custom-domains) > > Scope a domain to one pod for white-label tenant addresses. > [card] [API integration](/guides/api-integration) > > Per-inbox provisioning, skill files, and inbound routing. # Setup Source: https://docs.openmail.sh/guides/webhooks Add a webhook endpoint to receive inbound email events from OpenMail in real time. Covers endpoint setup, local development, testing, and signature verification. Add an endpoint on the [Webhooks](https://console.openmail.sh/webhooks) page. When inbound email arrives, OpenMail POSTs to it. ## Add an endpoint 1. Open [Webhooks](https://console.openmail.sh/webhooks) and click **Add endpoint**. 2. Enter your **Endpoint URL**. It must be publicly reachable. 3. Leave **Auth header or secret** empty to use an OpenMail signature. To use Standard Webhooks or a bearer token instead, see [Authentication](/concepts/webhooks#authentication). 4. Pick the **Events** to send. All events are selected by default. 5. Optionally narrow **Scope** to some pods or inboxes. Empty means the whole organisation. 6. Click **Create endpoint**. 7. On the endpoint page, copy the signing secret under **Authentication**. The examples below use the OpenMail signature. For the other schemes, see [Verification](/pages/webhooks/verification). ## 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. On the [Webhooks](https://console.openmail.sh/webhooks) page, [add an endpoint](#add-an-endpoint) with the URL `https://abc123.ngrok-free.app/webhooks` 7. Copy the endpoint's signing secret into `.env` as `OPENMAIL_WEBHOOK_SECRET` Free ngrok URLs can change when you restart ngrok. When that happens, open the endpoint, click **Edit** in its actions menu, and update the URL. ## 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); ``` > [!WARNING] > > 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. [Add an endpoint](#add-an-endpoint) with your ngrok URL, and set its secret in `.env`. 3. Open the endpoint on the [Webhooks](https://console.openmail.sh/webhooks) page and go to the **Testing** tab. 4. Pick an event type under **Send event** and click **Send Example**. Your server should log the request. 5. For a real event, send an email to one of your inbox addresses. The delivery shows up in the endpoint's **Message Attempts**, with its status and response code. > [!NOTE] > > Example events use placeholder IDs such as `inbox_example`. They are sent once, are not retried, and don't appear in the delivery logs. ## 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. * Add an endpoint for your production URL, or edit your existing one. Each endpoint has its own secret. * 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. Check the secret matches the endpoint's. Check `X-Timestamp` is within 5 minutes. | | Webhook not receiving events | Check the endpoint is enabled, subscribes to the event, and its scope covers the inbox. Check ngrok is running and its URL matches the endpoint. | | Deliveries failing | Open the endpoint's **Message Attempts** to see the response code or error, then click **Replay** once it's fixed. | | Port already in use | Change the port in your server and ngrok: `ngrok http 4000` | | ngrok URL changed | Restart ngrok and update the endpoint URL. | ## Related > [card] [Events](/pages/webhooks/events) > > Payload structure and field descriptions. > [card] [Verification](/pages/webhooks/verification) > > HMAC formula and verification steps. > [card] [Overview](/concepts/webhooks) > > Endpoints, delivery semantics, retry policy. > [card] [Connect a platform](/pages/webhooks/platforms) > > Send events to Grok, Hermes Agent, or OpenClaw. # 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()) ``` > [card] [WebSockets overview](/concepts/websockets) > > Why WebSockets, connecting, delivery semantics. > [card] [Protocol reference](/pages/websockets/protocol) > > Message types, subscribe options, event replay, connection management. > [card] [Event payload](/pages/webhooks/events) > > 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.](https://mintcdn.com/openmail/FxYTmvZrjkPbhbxQ/images/dither-hero.webp?fit=max\&auto=format\&n=FxYTmvZrjkPbhbxQ\&q=85\&s=21c715bdae214d51e79f20233a2222a3) ## Get started > [card] [Quickstart](/quickstart) > > Pick your agent framework and get it an email address. > [card] [CLI](/integrations/cli) > > Every `openmail` command, for agents that work from a shell. > [card] [API reference](/api-reference/introduction) > > Full API with interactive examples. > [card] [API integration](/guides/api-integration) > > Create inboxes and send mail programmatically. ## Explore > [card] [Inboxes](/concepts/inboxes) > > Dedicated addresses, scoped keys, and inbound routing. > [card] [WebSockets](/concepts/websockets) > > Real-time event streaming. No public URL needed. > [card] [Webhooks](/guides/webhooks) > > Server-to-server event delivery with retries. > [card] [Integrations](/integrations/mcp) > > MCP server, OpenClaw, Hermes Agent, Claude Code, and agent frameworks. ## Need help > [card] [Discord](https://discord.com/invite/eFfQFMZbsK) > > Ask in the OpenMail community. > [card] [Email support](mailto:support@openmail.to) > > Write to [support@openmail.to](mailto:support@openmail.to). > [card] [FAQ](/pages/resources/faq) > > 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 > [card] [API integration](/guides/api-integration) > > Create inboxes, inject env vars, and handle inbound for multi-tenant apps. > [card] [API reference](/api-reference/introduction) > > Full endpoint documentation. > [card] [WebSockets](/concepts/websockets) > > Real-time event streaming — no public URL needed. > [card] [Attachments](/concepts/attachments) > > 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. Prefer no install? Use the [MCP server](/integrations/mcp) instead. 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 **1. Install the CLI** ```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). **2. Create an inbox** ```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`. **3. Install the skill** ```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. **4. Verify** 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 --bcc crm@hubspot.example --subject "Hi" --body "Hi" # bcc a CRM logging address 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, `--bcc` for anyone who should get a copy without appearing on it. 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 **1. Install the plugin** ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes plugins install openmail --enable ``` **2. Run setup** ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes openmail setup ``` Paste the key. Setup picks your inbox, or creates one, then asks who may email the agent: a list of addresses, anyone your [sender rules](/concepts/sender-rules) let through, or decide later. **3. Start the gateway** ```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 Setup asks. Either list the addresses (`OPENMAIL_ALLOWED_USERS`), or let OpenMail decide and manage 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 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, bcc 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) | Tools default to the agent's own inbox. The bundled [`openmail` skill](/integrations/skill-files/openmail) teaches it the [CLI](/integrations/cli) for administration: sender rules, pods, deleting inboxes. 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 Subagents run inside the agent and share its key and inboxes. To give one its own address, the agent creates an inbox and passes that `inbox_id` when it sends. A [Bot](https://hermes-agent.nousresearch.com/docs/user-guide/bot-mode) is a separate Hermes profile with its own `.env`. Give it its own key: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} hermes -p research plugins install openmail --enable hermes -p research openmail setup --inbox research@omail.sh ``` Paste a pod or account key when asked. Setup stores a key that reaches only that inbox; the key you pasted is not kept. ## 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 files from `~/.hermes/media` or `~/.hermes/output`. ## 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 | | `OPENMAIL_HOME_ADDRESS` | Where `--deliver openmail` sends. Also silences the one-time "no home channel" notice Hermes would otherwise send new correspondents | | `OPENMAIL_BASE_URL` | API host. Default `https://api.openmail.sh` | Skipping setup? `OPENMAIL_API_KEY` plus `OPENMAIL_ALLOWED_USERS` or `OPENMAIL_ALLOW_ALL_USERS=true` are enough. Scripted: `hermes openmail setup --api-key -y`; add `--allow-all` to open the gate, `--inbox
` to run as one inbox. ## 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 # MCP server Source: https://docs.openmail.sh/integrations/mcp Connect ChatGPT, Claude, Cursor, or any MCP client to OpenMail. One hosted URL gives your agent an inbox so it can read, send, and reply to email. Install the plugin in ChatGPT, or add one URL to any other client. Your agent can then create an inbox, read threads, and reply. OAuth or API key; nothing to install locally. ## Connect > [tab] ChatGPT > > **1. Install the plugin** > > Open the [OpenMail listing](https://chatgpt.com/plugins/plugin_asdk_app_6ad21ddbacdc8191a2a9a711aa63487c) and click **Install plugin**. Or open **Plugins** in ChatGPT and search for **OpenMail**. > > ![OpenMail listing in the ChatGPT plugin directory with the Install plugin button highlighted](https://mintcdn.com/openmail/2pjIzprLnUOAoM4j/images/mcp/chatgpt-listing.png?fit=max\&auto=format\&n=2pjIzprLnUOAoM4j\&q=85\&s=0a32ded1b06f5e295dfcda11b317c3de) > > **2. Connect** > > ChatGPT explains what it shares with the plugin. Click **Connect OpenMail**. > > ![ChatGPT Connect OpenMail dialog with the Connect OpenMail button highlighted](https://mintcdn.com/openmail/2pjIzprLnUOAoM4j/images/mcp/chatgpt-connect.png?fit=max\&auto=format\&n=2pjIzprLnUOAoM4j\&q=85\&s=66bdb8f219d57ee37f54caf1bc685051) > > **3. Approve in the console** > > The console opens: sign in, pick an organisation if you belong to several, and untick any scopes the agent should not have. **Read mail** is always granted; **Set up inboxes**, **Send mail**, and **Domains** are optional. Click **Allow**. Revoke any time from [MCP settings](https://console.openmail.sh/settings/mcp). > > ![OpenMail console consent page listing scopes with the Allow button highlighted](https://mintcdn.com/openmail/2pjIzprLnUOAoM4j/images/mcp/chatgpt-consent.png?fit=max\&auto=format\&n=2pjIzprLnUOAoM4j\&q=85\&s=e8967167fdefd2995658437ff1d1238e) > > **4. Confirm** > > Start a new chat and ask *"Which OpenMail inbox can you see?"*, or mention `@OpenMail`. ChatGPT asks before any write action, such as sending mail. > > ![ChatGPT answering which OpenMail inboxes it can access](https://mintcdn.com/openmail/2pjIzprLnUOAoM4j/images/mcp/chatgpt-first-message.png?fit=max\&auto=format\&n=2pjIzprLnUOAoM4j\&q=85\&s=f725615cf62b15ee939f160d5b961a48) > > > [!NOTE] > > > > The directory is available on every ChatGPT plan. In a Business, Enterprise, or Edu workspace, an admin may need to allow the plugin first. The same plugin works in Codex: run `/plugins` in Codex CLI, or open **Sources → Use plugins** in the desktop app. > [tab] Claude (OAuth) > > **1. Open Connectors** > > Go to **Settings → Connectors**, click **Add**, then **Add custom connector**. > > ![Claude Connectors settings with the Add menu open and Add custom connector highlighted](https://mintcdn.com/openmail/shproCGer-XtDsSk/images/mcp/claude-connectors.png?fit=max\&auto=format\&n=shproCGer-XtDsSk\&q=85\&s=2932097cb3bad397788ab182d6c84e90) > > **2. Name the server** > > | Field | Value | > | - | - | > | Name | OpenMail | > | MCP server URL | `https://mcp.openmail.sh/mcp` | > > Click **Continue**. Claude checks the URL and detects the sign-in settings. > > ![Add custom connector dialog with the MCP server URL field highlighted](https://mintcdn.com/openmail/shproCGer-XtDsSk/images/mcp/claude-add-connector.png?fit=max\&auto=format\&n=shproCGer-XtDsSk\&q=85\&s=afc0e10831bd31e8329d60f9d142497b) > > **3. Keep the detected auth settings** > > | Field | Value | > | - | - | > | Authentication | **Sign in now** | > | OAuth client | **Use Claude's published identity** (or **Register automatically**) | > | Request headers | none | > > Both are pre-selected and marked **Detected**. Leave **Advanced** alone and click **Add**. > > ![Add custom connector step 2 with Sign in now and Use Claude's published identity highlighted](https://mintcdn.com/openmail/shproCGer-XtDsSk/images/mcp/claude-auth.png?fit=max\&auto=format\&n=shproCGer-XtDsSk\&q=85\&s=411431a72c9f5fee59bf728be6a10a4d) > > **4. Sign in and approve** > > The console opens and shows the client name and host. Pick an organisation if you belong to several. **Read** is always granted; untick **Send**, **Setup**, or **Domains** to withhold them. Revoke any time from [MCP settings](https://console.openmail.sh/settings/mcp). > > **5. Confirm** > > Ask *"Which OpenMail inbox can you see?"*. Claude calls `auth_me` and answers with the organisation and inbox. > > > [!NOTE] > > > > Custom connectors need a Claude Pro, Max, Team, or Enterprise plan. > [tab] Claude Code > > ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} > claude mcp add --transport http openmail https://mcp.openmail.sh/mcp > ``` > > Claude Code signs in with OAuth on first use. Connectors added on claude.ai do not carry over, so add it here too. Confirm with *"Which OpenMail inbox can you see?"*. > > Prefer the CLI and skill? See [Claude Code](/integrations/claude-code). > [tab] Cursor > > Install the [OpenMail plugin](https://cursor.directory/plugins/openmail) from cursor.directory, or click once here and sign in when Cursor prompts you: > > ![Add OpenMail MCP server to Cursor](https://cursor.com/deeplink/mcp-install-light.svg) > > Add OpenMail MCP server to Cursor > > [Open](cursor://anysphere.cursor-deeplink/mcp/install?name=openmail\&config=eyJ1cmwiOiJodHRwczovL21jcC5vcGVubWFpbC5zaC9tY3AifQ==) > > Or edit `.cursor/mcp.json` by hand. Drop `headers` for OAuth; keep it for a headless agent: > > ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} > { > "mcpServers": { > "openmail": { > "url": "https://mcp.openmail.sh/mcp", > "headers": { "Authorization": "Bearer om_YOUR_KEY" } > } > } > } > ``` > > > [!TIP] > > > > Use an inbox-scoped key, from the console or `openmail inbox keys create`. Keys go in the header, never the URL. > > Confirm with *"Which OpenMail inbox can you see?"* in a Cursor chat. > [tab] VS Code / registry > > OpenMail is in the [MCP Registry](https://registry.modelcontextprotocol.io) as `sh.openmail/openmail`. In VS Code, run **MCP: Add Server**, search for **OpenMail**, and sign in when prompted. Any registry-aware client installs it the same way. > [tab] stdio-only clients > > Bridge with `mcp-remote`: > > ```json theme={"theme":{"light":"github-light","dark":"dark-plus"}} > { > "mcpServers": { > "openmail": { > "command": "npx", > "args": ["-y", "mcp-remote", "https://mcp.openmail.sh/mcp"] > } > } > } > ``` > [!NOTE] > > Without a credential, `/mcp` answers `401` and your client starts sign-in. An agent that only needs these docs can use `https://mcp.openmail.sh/mcp/public`: no sign-in, just `search_docs` and `get_docs`. ## First run Ask your agent: *"Set up an inbox for this agent and show me the address."* It calls `setup_agent_email`, which shows a plan first. Confirm, and it creates the inbox. Over OAuth no key is minted; the session already has access, and the tool points to the console if another agent needs one. Over an API key, a new inbox-scoped key appears once in the result. > [!WARNING] > > Move that key to the agent's environment now. If the transcript is shared, revoke it in the console. ## Tools > [!NOTE] > > 19 tools. Your client reads the live list from the server; this table can lag a release. | Tool | What it does | | - | - | | `search_docs`, `get_docs` | Search and read these docs. Also available without sign-in at `/mcp/public`. | | `auth_me` | Which organisation and inbox this credential can see. | | `setup_agent_email` | Plan, then create an inbox and mint a scoped key. | | `list_inboxes`, `get_inbox`, `create_inbox`, `mint_inbox_key` | Inbox management. | | `list_unread_threads` | Unread threads whose latest message is `autoReplyable`: sent by a person or another agent. Spam, marketing, bounces, and machine-generated mail (no-reply, OTPs, receipts) are filtered out. | | `read_thread` | Full thread as Markdown with timestamps, `autoReplyable` flags, and attachment message ids. Quoted reply history is stripped. | | `reply_to_thread`, `send_email` | Reply in-thread, or start a new one. `send_email` names the inbox it used when you did not pass `inbox_id`. | | `mark_thread_read` | Mark a thread handled. | | `get_attachment_text` | Extracted text from an attachment. Use the `message:` id shown in `read_thread`. | | `list_domains`, `add_domain`, `get_domain`, `verify_domain` | Custom domains with pasteable DNS records. Pro plan. | | `send_mcp_feedback` | Report a confusing or broken tool to the OpenMail team. | Tools follow the credential. An inbox-scoped key hides `list_inboxes`, `setup_agent_email`, and the domain tools. ## Narrow what the agent can do | Goal | URL | | - | - | | Read only, no sending | `https://mcp.openmail.sh/mcp/readonly` or `?readonly=true` | | One inbox, no discovery | `https://mcp.openmail.sh/mcp/inbox/INBOX_ID` | | Fewer tools | `?toolsets=mailbox,docs` or the `X-MCP-Toolsets` header | ## What the connected client can do A connected client sees whatever the tools return: thread contents, attachment text, inbox addresses. With **Send** it can also send and reply as your inbox. That data falls under the client's own terms, so connect only clients you trust and untick scopes the agent does not need. Revoking the connection in [MCP settings](https://console.openmail.sh/settings/mcp), or its API key, stops access at once. ## Usage analytics The server records how tools are used so the OpenMail team can fix the ones that fail or confuse agents. Each tool accepts an optional `context` argument: one line from the agent on what it is trying to do. That line is recorded too. | Recorded | Never recorded | | - | - | | Tool name, success or failure, latency | Message bodies, subjects, recipients | | Client name and version (Claude, Cursor, ChatGPT) | Attachment text | | The agent's `context` line | Inbox names and display names | | Your organisation id | API keys and OAuth tokens | Tool results never leave the server, except from `search_docs` and `get_docs`, which return public documentation. ## Troubleshooting > [accordion] The consent page asks for an account you do not have > > Sign up at [console.openmail.sh](https://console.openmail.sh/signup), then connect again from your client. > [accordion] You picked the wrong organisation > > Revoke it in [MCP settings](https://console.openmail.sh/settings/mcp) and connect again. The consent page shows the picker each time. > [accordion] 401 with an API key > > Check the key starts with `om_`, is complete, and is not revoked. It belongs in the `Authorization: Bearer` header, not the URL. A leftover `om_YOUR_KEY` placeholder in Cursor gives the same error. > [accordion] Tools are missing > > An inbox-scoped key hides `list_inboxes`, `setup_agent_email`, and the domain tools on purpose. After changing scopes, disconnect and reconnect so the client reloads the list. > [accordion] OpenMail is not in ChatGPT's plugin directory > > Check you are signed in to the right account or workspace. In a Business, Enterprise, or Edu workspace, an admin controls which plugins members can install; ask them to allow OpenMail from the Admin console. > [accordion] ChatGPT installed the plugin but will not use it > > Start a new chat; installed plugins only load into new conversations. Then check **Settings → Plugins** shows OpenMail as **Connected**. If it does not, click **Connect** and sign in again. > [accordion] Claude says it cannot reach OpenMail > > Check **Settings → Connectors** shows OpenMail as connected. If not, repeat the Claude steps. ## Example prompts * Set up an inbox for this agent and show me the address. * Reply to my unread mail. * Read the invoice attached to the latest thread and summarise it. * Add and verify `mail.example.com`. ## Related * [Claude Code](/integrations/claude-code): CLI and skill instead of MCP * [Agent keys](/best-practices/api-key-scopes): why inbox-scoped keys * Source: [openmailsh/mcp](https://github.com/openmailsh/mcp) (MIT) # 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 **1. Install the plugin** ```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. **2. Add the channel** ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} openclaw channels add --channel openmail --api-key ``` Picks your inbox, or creates one. **3. Restart the gateway** ```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. **1. Stop the old bridge** ```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 ``` **2. Install the plugin** Follow [Install](#install) with the key from `~/.openclaw/openmail.env`. **3. Remove the old files** 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? Add one or more endpoints on the [Webhooks](https://console.openmail.sh/webhooks) page. When inbound email arrives, we POST a `message.received` event to each matching endpoint. 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 webhook endpoints only (not over [WebSockets](/concepts/websockets)). An endpoint receives them only if you select them in its **Events**. ## 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": "3a1f9c2e-7b4d-4e8a-9c1f-2d5e6a7b8c9d", "occurred_at": "2024-03-21T10:05:00.000Z", "inbox_id": "inb_92ma...", "thread_id": "thr_xyz...", "message": { "id": "msg_...", "rfc_message_id": "", "from": "sender@example.com", "to": "inbox@omail.sh", "header_to": "inbox@omail.sh", "delivery_role": "to", "cc": [], "address_names": { "sender@example.com": "Sam Sender" }, "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", "category": "personal", "auto_replyable": true, "verdict": "clean", "spam_score": 0.4 } } ``` ## Field descriptions | Field | Description | | - | - | | `event` | Event type — e.g. `message.received` (see [Events](#events)) | | `event_id` | Unique ID for this event. Same across retries and endpoints. Use for deduplication. | | `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.to` | Envelope recipient address | | `message.cc` | Cc addresses | | `message.address_names` | Display names from the From, To, and Cc headers, keyed by address. `null` when none. | | `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. | | `message.category` | `personal`, `automated`, `marketing`, `bounce`, `spam`, or `malicious` | | `message.auto_replyable` | `false` for mail an agent should not auto-reply to (automated, bulk, bounces, spam) | ## 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": "8c2d4f6a-1b3e-4c5d-8e7f-9a0b1c2d3e4f", "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 Every request has `Content-Type: application/json`. The other headers depend on the endpoint's authentication scheme: | Scheme | Headers | | - | - | | OpenMail signature | `X-Timestamp`, `X-Signature`, `X-Event-Id` | | Standard Webhooks | `webhook-id`, `webhook-timestamp`, `webhook-signature` | | Bearer token | `Authorization`, `X-Event-Id` | See [Verification](/pages/webhooks/verification) for what each header means and how to check it. ## Browse events in the console The **Event Catalog** tab on the [Webhooks](https://console.openmail.sh/webhooks) page lists every event type with its schema and an example payload. > [card] [Verification](/pages/webhooks/verification) > > Verify each authentication scheme. > [card] [Setup](/guides/webhooks) > > Full implementation guide. # Connect a platform Source: https://docs.openmail.sh/pages/webhooks/platforms Send OpenMail webhook events straight to Grok automations, Hermes Agent, or OpenClaw, so inbound email starts an agent run without your own server. Some agent platforms accept webhooks directly. Point an OpenMail endpoint at one and inbound email starts an agent run, with no server of your own in between. Each section below follows the same steps: 1. Get a webhook URL and a secret or token from the platform. 2. On the [Webhooks](https://console.openmail.sh/webhooks) page, click **Add endpoint**. 3. Paste the URL into **Endpoint URL** and the secret or token into **Auth header or secret**. OpenMail picks the [authentication scheme](/concepts/webhooks#authentication) from its shape. 4. Under **Events**, pick `message.received` unless you also want the lifecycle events. 5. On the endpoint's **Testing** tab, click **Send Example** to check the platform accepts it. > [!NOTE] > > Hermes Agent and OpenClaw are self-hosted. OpenMail can only reach a public HTTPS URL, so expose your gateway through a reverse proxy or a tunnel such as [ngrok](https://ngrok.com/) or Tailscale Funnel. A `localhost` or private address won't work. ## Grok automations [Grok automation webhook triggers](https://docs.x.ai/grok/automations/webhooks) verify Standard Webhooks signatures, which OpenMail sends when you paste a `whsec_` secret. 1. In Grok, create or edit an automation and add a **Webhook** trigger. 2. Save the automation, then copy the endpoint and signing secret shown under the trigger. Grok shows the secret only once. 3. In OpenMail, add an endpoint with the Grok endpoint as the URL and the `whsec_` signing secret as the secret. The endpoint page shows **Standard Webhooks** under **Authentication**. If you rotate the secret in Grok, click **Replace** there and paste the new one. ## Hermes Agent The [Hermes Agent webhook gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks) verifies Standard Webhooks signatures on any route. 1. Generate a secret: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} echo "whsec_$(openssl rand -base64 32)" ``` 2. Add a route to `~/.hermes/config.yaml` with that secret. The `prompt` template reads fields from the OpenMail payload: ```yaml theme={"theme":{"light":"github-light","dark":"dark-plus"}} platforms: webhook: enabled: true extra: port: 8644 routes: openmail: secret: "whsec_..." prompt: | New email to {message.to} from {message.from} Subject: {message.subject} {message.body_text} Thread: {thread_id} ``` 3. Restart the gateway. The route listens at `/webhooks/openmail`. 4. In OpenMail, add an endpoint with the public URL of that route, for example `https://hermes.example.com/webhooks/openmail`, and the same `whsec_` secret. Leave the route's `events` list empty. Hermes reads the event type from fields OpenMail doesn't send, so a filter would drop every request. Choose events on the OpenMail endpoint instead. ## OpenClaw [OpenClaw gateway hooks](https://docs.openclaw.ai/gateway/config-hooks) authenticate with a bearer token, which OpenMail sends when you paste anything that isn't a `whsec_` secret. OpenClaw's agent hook needs a `message` string, and the OpenMail payload doesn't have one. Add a mapping that builds the message from the payload: ```json5 theme={"theme":{"light":"github-light","dark":"dark-plus"}} { hooks: { enabled: true, token: "", mappings: [ { id: "openmail", match: { path: "openmail" }, action: "agent", name: "OpenMail", messageTemplate: "New email to {{message.to}} from {{message.from}}\nSubject: {{message.subject}}\n\n{{message.body_text}}\n\nThread: {{thread_id}}", }, ], }, } ``` 1. Add the config above, with your own long random value for `hooks.token`. Use a different value from your gateway auth token. 2. Reload the gateway. The mapping listens at `/hooks/openmail`. 3. In OpenMail, add an endpoint with the public URL of that path, for example `https://openclaw.example.com/hooks/openmail`, and paste the hook token as the secret. 4. Under **Events**, pick only `message.received`. Lifecycle events have no `message`, so the template would render an almost empty prompt. The endpoint page shows **Auth header** under **Authentication**. OpenClaw limits hook bodies to 256 KiB, so very large emails may be rejected. > [!TIP] > > To install OpenMail as an OpenClaw plugin instead, with replies sent from the agent's inbox, see [OpenClaw](/integrations/openclaw). ## Related > [card] [Overview](/concepts/webhooks) > > Endpoints, scoping, and authentication. > [card] [Events](/pages/webhooks/events) > > Payload fields you can use in templates. # Verification Source: https://docs.openmail.sh/pages/webhooks/verification Verify OpenMail webhook requests. Covers the OpenMail HMAC signature, Standard Webhooks signatures, and bearer tokens, with code examples and replay protection. Always verify a webhook before you process it. Never trust an unverified payload. ## Why verify? Anyone who finds your webhook URL can send it fake requests. Verification proves the request came from OpenMail. Always verify in production. ## Pick your scheme Each endpoint uses one scheme. OpenMail picks it from the **Auth header or secret** field when you add or edit the endpoint on the [Webhooks](https://console.openmail.sh/webhooks) page: | Field value | Scheme | Headers | | - | - | - | | Empty | [OpenMail signature](#openmail-signature) | `X-Timestamp`, `X-Signature`, `X-Event-Id` | | Starts with `whsec_` | [Standard Webhooks](#standard-webhooks) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | | Anything else | [Bearer token](#bearer-token) | `Authorization`, `X-Event-Id` | The endpoint page shows which scheme it uses under **Authentication**. ## OpenMail signature Leave the field empty and OpenMail generates a secret for the endpoint. Every request carries: | Header | Description | | - | - | | `X-Event-Id` | Stable event ID. Same across retries. Use for deduplication. | | `X-Timestamp` | Unix timestamp (seconds) of this attempt | | `X-Signature` | Hex HMAC-SHA256 signature | ### Signature format ``` HMAC-SHA256(webhook_secret, "{timestamp}.{raw_json_payload}") ``` * `timestamp` = value of the `X-Timestamp` header * `raw_json_payload` = raw request body as a string (do not parse the JSON first) ### Verification steps 1. Read the raw request body as a string. 2. Get `X-Timestamp` and `X-Signature` from the headers. 3. Compute `HMAC-SHA256(secret, timestamp + "." + body)` as hex. 4. Compare it with `X-Signature` using **constant-time comparison** (e.g. `crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python). 5. Check `X-Timestamp` is within 5 minutes of the current time (replay protection). > [!WARNING] > > Use constant-time comparison to prevent timing attacks. Do not use `==` or string equality. ```javascript Node.js 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"); const a = Buffer.from(signature, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ```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 Open the endpoint on the [Webhooks](https://console.openmail.sh/webhooks) page. Under **Authentication**, click the eye icon to reveal the signing secret, or the copy icon to copy it. Store it in an environment variable (e.g. `OPENMAIL_WEBHOOK_SECRET`) and never commit it to version control. To replace it, open the endpoint's actions menu and click **Rotate secret**. The old secret stops working immediately. ## Standard Webhooks Paste a `whsec_` secret and OpenMail signs each request per the [Standard Webhooks](https://www.standardwebhooks.com/) spec. Use this for receivers that already verify Standard Webhooks, such as [Grok automations](/pages/webhooks/platforms#grok-automations). The part after `whsec_` must be base64. Every request carries: | Header | Description | | - | - | | `webhook-id` | The event ID. Same across retries. Use for deduplication. | | `webhook-timestamp` | Unix timestamp (seconds) of this attempt | | `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 signature | ### Signature format ``` key = base64_decode(secret without the "whsec_" prefix) signature = base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw_json_payload}")) ``` ### Verification steps 1. Read the raw request body as a string. 2. Strip `whsec_` from your secret and base64-decode the rest. This is the HMAC key. 3. Compute the signature over `{webhook-id}.{webhook-timestamp}.{body}`. 4. Compare it with the value after `v1,` in `webhook-signature`, in constant time. 5. Check `webhook-timestamp` is within 5 minutes of the current time. ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const crypto = require("crypto"); function verifyStandardWebhook(payload, headers, secret) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto .createHmac("sha256", key) .update(`${id}.${timestamp}.${payload}`) .digest("base64"); return (headers["webhook-signature"] || "") .split(" ") .some((sig) => { const [version, value] = sig.split(","); if (version !== "v1" || !value) return false; const a = Buffer.from(value); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); }); } ``` ```python Python theme={"theme":{"light":"github-light","dark":"dark-plus"}} import base64 import hashlib import hmac def verify_standard_webhook(payload: bytes, headers: dict, secret: str) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + payload expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for sig in headers.get("webhook-signature", "").split(" "): version, _, value = sig.partition(",") if version == "v1" and hmac.compare_digest(value, expected): return True return False ``` You can also use the official [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks), which check the timestamp for you. ### Getting your secret You create the `whsec_` secret, or the receiving platform gives you one. To make your own: ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}} echo "whsec_$(openssl rand -base64 32)" ``` OpenMail never shows a pasted secret again; the endpoint page shows only its last four characters. To change it, click **Replace** under **Authentication** and paste a new one. ## Bearer token Paste any other value and OpenMail sends it as a bearer token. You can paste the whole `Authorization: Bearer …` header or just the token. Every request carries: | Header | Description | | - | - | | `Authorization` | `Bearer ` | | `X-Event-Id` | Stable event ID. Same across retries. Use for deduplication. | The request is not signed and has no timestamp. Compare the token with the one you expect, in constant time: ```javascript Node.js theme={"theme":{"light":"github-light","dark":"dark-plus"}} const crypto = require("crypto"); function verifyBearer(authorization, token) { const a = Buffer.from(authorization || ""); const b = Buffer.from(`Bearer ${token}`); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` > [!NOTE] > > A bearer token proves who sent the request, but not that the body is unchanged, and it gives no replay protection. Use it for receivers that only accept a token, and only over HTTPS. Prefer a signature when your receiver supports one. As with `whsec_` secrets, OpenMail never shows the token again. Click **Replace** under **Authentication** to change it. ## Troubleshooting | Issue | Solution | | - | - | | Signature verification fails | Use the raw request body; do not parse the JSON first. Check the secret matches the endpoint. | | Wrong scheme | Check **Authentication** on the endpoint page. A value starting with `whsec_` uses Standard Webhooks; any other value is sent as a bearer token. | | Wrong secret | For an OpenMail signature, click **Rotate secret** in the endpoint's actions menu and update your env. For a pasted secret or token, click **Replace**. | | Timestamp expired | Check the timestamp is within 5 minutes of the current time. Check your server clock is in 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. > [!NOTE] > > 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. > [!NOTE] > > 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 | > [card] [Overview](/concepts/websockets) > > Why WebSockets, connecting, delivery semantics. > [card] [Quickstart](/guides/websockets/quickstart) > > Connect and receive events in under 10 lines. > [card] [Webhook events](/pages/webhooks/events) > > 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. ## 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. > [card] [MCP server](/integrations/mcp) > > One hosted URL for ChatGPT, Claude, Cursor, and any MCP client. No install. > [card] [OpenClaw](/integrations/openclaw) > > Install the plugin and enable your agent to send and read emails. > [card] [Hermes Agent](/integrations/hermes) > > Plugin with guided setup. Three command lines and OpenMail is live. > [card] [Claude Code](/integrations/claude-code) > > Install the CLI and the `openmail` skill. Also works for Cursor and Codex. > [card] [CLI](/integrations/cli) > > Every `openmail` command, for agents and scripts that work from a shell. > [card] [API / SDK](/guides/api-integration) > > 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 > [card] [Handle inbound email](/concepts/websockets) > > Route replies to your agent over WebSockets or [webhooks](/guides/webhooks). > [card] [Email deliverability](/best-practices/email-deliverability) > > Warm-up schedules and content best practices. > [card] [Threading](/concepts/threading) > > How threads work across inbound and outbound. > [card] [API reference](/api-reference/introduction) > > Every endpoint, for programmatic workflows.