diff --git a/api-reference/openapi.json b/api-reference/openapi.json index 4306c1a..4bd0cba 100644 --- a/api-reference/openapi.json +++ b/api-reference/openapi.json @@ -93,6 +93,10 @@ "domain": { "type": "string", "description": "Domain for the inbox address. Defaults to your account domain (e.g. `openmail.sh`). Pass a verified custom domain you own to create the inbox on it (e.g. `agent-mail.example.com`). The domain must already be added and verified for your account; the account default never switches automatically." + }, + "webhookUrl": { + "type": "string", + "description": "Optional HTTPS URL to receive inbound events for this inbox. When set, OpenMail generates a per-inbox signing secret (returned once in the response). Falls back to the account webhook when omitted." } } } @@ -214,6 +218,11 @@ "nullable": true, "maxLength": 200, "description": "Sender display name. Set null to clear." + }, + "webhookUrl": { + "type": "string", + "nullable": true, + "description": "Per-inbox webhook URL (HTTPS). Set a value to point this inbox at its own endpoint (a signing secret is generated and returned once on first set). Set null or an empty string to clear it and fall back to the account webhook." } } } @@ -240,6 +249,16 @@ } } } + }, + "422": { + "description": "Invalid webhook URL (failed SSRF/format validation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } }, @@ -273,6 +292,127 @@ } } }, + "/v1/inboxes/{id}/webhook/rotate-secret": { + "post": { + "summary": "Rotate inbox webhook secret", + "description": "Generate a new HMAC signing secret for the inbox webhook. The inbox must already have a webhook URL set. The new secret is returned once.", + "operationId": "rotateInboxWebhookSecret", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "New secret", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "webhookSecret": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "No webhook URL set on this inbox", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/inboxes/{id}/webhook/test": { + "post": { + "summary": "Send a test webhook", + "description": "Send a signed `setup.test` event to the inbox webhook URL to verify connectivity and signature handling.", + "operationId": "testInboxWebhook", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Test delivered", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "No webhook URL set on this inbox", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "The webhook endpoint did not accept the test event", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/v1/inboxes/{id}/send": { "post": { "summary": "Send email", @@ -844,6 +984,15 @@ "description": "Sender display name", "nullable": true }, + "webhookUrl": { + "type": "string", + "nullable": true, + "description": "Per-inbox webhook URL. When set, inbound events for this inbox are delivered here instead of the account webhook. Null means the account-level webhook is used." + }, + "webhookSecret": { + "type": "string", + "description": "HMAC signing secret for the inbox webhook. Returned only in the response that creates or rotates it — store it securely; it is not returned again." + }, "createdAt": { "type": "string", "format": "date-time" diff --git a/concepts/inboxes.mdx b/concepts/inboxes.mdx index c8e6ff8..e65062d 100644 --- a/concepts/inboxes.mdx +++ b/concepts/inboxes.mdx @@ -43,6 +43,8 @@ Route inbound events by `inbox_id`. Map each inbox ID to your user/agent/contain **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. +**Per-inbox webhooks** - Instead of routing every event through one account webhook, you can give each inbox its own `webhookUrl` so each agent receives mail on its own endpoint. See [Webhooks](/concepts/webhooks#account-vs-per-inbox-webhooks). + See [Email deliverability](/best-practices/email-deliverability) for warm-up schedules and content best practices. diff --git a/concepts/webhooks.mdx b/concepts/webhooks.mdx index 3f95754..44f435c 100644 --- a/concepts/webhooks.mdx +++ b/concepts/webhooks.mdx @@ -13,6 +13,33 @@ Instead of constantly polling the API to check for new emails, you register a UR - **Real-time** - Build agents that reply to emails in seconds. - **Efficient** - No polling; saves compute and simplifies your logic. +## Account vs. per-inbox webhooks + +You can configure webhooks at two levels: + +- **Account webhook** - A single URL for your whole account, set in **Settings**. Every inbox without its own webhook delivers here. +- **Per-inbox webhook** - A URL set on an individual inbox. Useful when you provision many inboxes (for example one per agent) and want each to receive its own mail on its own endpoint. + +OpenMail resolves the delivery target for each event in this order: + +1. An active real-time WebSocket connection for the account +2. The inbox's own `webhookUrl` (if set) +3. The account `webhookUrl` (fallback) + +Each webhook is signed with the secret that belongs to the URL it is delivered to. Set a per-inbox webhook when creating an inbox or by updating it: + +```bash +# Create an inbox with its own webhook +curl -X POST https://api.openmail.sh/v1/inboxes \ + -H "Authorization: Bearer $OPENMAIL_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "mailboxName": "agent-42", "webhookUrl": "https://hooks.example.com/agent-42" }' + +# The response includes a one-time webhookSecret — store it. +``` + +Clear a per-inbox webhook (revert to the account webhook) by setting it to `null` via `PATCH /v1/inboxes/{id}`. Rotate its secret with `POST /v1/inboxes/{id}/webhook/rotate-secret`, and verify connectivity with `POST /v1/inboxes/{id}/webhook/test`. + ## Delivery semantics - **At-least-once delivery** - We may deliver the same event more than once. Use `event_id` to deduplicate. diff --git a/scripts/generate-openapi.js b/scripts/generate-openapi.js index a6379a0..5d8cad8 100644 --- a/scripts/generate-openapi.js +++ b/scripts/generate-openapi.js @@ -78,6 +78,11 @@ const spec = { description: "Domain for the inbox address. Defaults to your account domain (e.g. `openmail.sh`). Pass a verified custom domain you own to create the inbox on it (e.g. `agent-mail.example.com`). The domain must already be added and verified for your account; the account default never switches automatically.", }, + webhookUrl: { + type: "string", + description: + "Optional HTTPS URL to receive inbound events for this inbox. When set, OpenMail generates a per-inbox signing secret (returned once in the response). Falls back to the account webhook when omitted.", + }, }, }, }, @@ -144,6 +149,12 @@ const spec = { maxLength: 200, description: "Sender display name. Set null to clear.", }, + webhookUrl: { + type: "string", + nullable: true, + description: + "Per-inbox webhook URL (HTTPS). Set a value to point this inbox at its own endpoint (a signing secret is generated and returned once on first set). Set null or an empty string to clear it and fall back to the account webhook.", + }, }, }, }, @@ -158,6 +169,10 @@ const spec = { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, }, + "422": { + description: "Invalid webhook URL (failed SSRF/format validation)", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, }, }, delete: { @@ -173,6 +188,73 @@ const spec = { }, }, }, + "/v1/inboxes/{id}/webhook/rotate-secret": { + post: { + summary: "Rotate inbox webhook secret", + description: + "Generate a new HMAC signing secret for the inbox webhook. The inbox must already have a webhook URL set. The new secret is returned once.", + operationId: "rotateInboxWebhookSecret", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { + description: "New secret", + content: { + "application/json": { + schema: { + type: "object", + properties: { webhookSecret: { type: "string" } }, + }, + }, + }, + }, + "400": { + description: "No webhook URL set on this inbox", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + "404": { + description: "Not found", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + }, + }, + }, + "/v1/inboxes/{id}/webhook/test": { + post: { + summary: "Send a test webhook", + description: + "Send a signed `setup.test` event to the inbox webhook URL to verify connectivity and signature handling.", + operationId: "testInboxWebhook", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { + description: "Test delivered", + content: { + "application/json": { + schema: { + type: "object", + properties: { + ok: { type: "boolean" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + "400": { + description: "No webhook URL set on this inbox", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + "404": { + description: "Not found", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + "502": { + description: "The webhook endpoint did not accept the test event", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + }, + }, + }, "/v1/inboxes/{id}/send": { post: { summary: "Send email", @@ -474,6 +556,17 @@ const spec = { id: { type: "string", description: "OpenMail inbox ID" }, address: { type: "string", format: "email", description: "Full email address" }, displayName: { type: "string", description: "Sender display name", nullable: true }, + webhookUrl: { + type: "string", + nullable: true, + description: + "Per-inbox webhook URL. When set, inbound events for this inbox are delivered here instead of the account webhook. Null means the account-level webhook is used.", + }, + webhookSecret: { + type: "string", + description: + "HMAC signing secret for the inbox webhook. Returned only in the response that creates or rotates it — store it securely; it is not returned again.", + }, createdAt: { type: "string", format: "date-time" }, }, },