REST API for Commune email infrastructure. Manage domains, inboxes, threads, messages, and attachments programmatically.
Base URL: https://your-server.example.com/v1
All requests require an API key in the Authorization header:
Authorization: Bearer comm_your_api_key_here
API keys are scoped with permissions. The default read + write permissions grant full access. Fine-grained scopes are available:
| Scope | Grants |
|---|---|
domains:read |
List/get domains and DNS records |
domains:write |
Create domains, trigger verification |
inboxes:read |
List/get inboxes |
inboxes:write |
Create, update, delete inboxes |
threads:read |
List threads, get thread messages |
messages:read |
List messages |
messages:write |
Send emails |
attachments:read |
Get attachment metadata and URLs |
attachments:write |
Upload attachments |
Get from zero to sending emails in 2 API calls — no domain setup needed:
API_KEY="comm_..."
BASE="https://your-server.example.com/v1"
# 1. Create an inbox (domain auto-assigned)
# Save the returned "id" and "domain_id" from the response
curl -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"local_part":"support"}' \
$BASE/inboxes
# 2. Send an email (use the inboxId from step 1)
curl -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"user@example.com","subject":"Hello","text":"Hi there!","inboxId":"{inbox_id}"}' \
$BASE/messages/sendImportant: You must specify where to send from — provide
inboxId(recommended), or afromaddress that matches one of your inboxes. ThedomainIdis automatically inferred from the inbox.
For more control, you can also manage custom domains, set webhooks, and browse threads with pagination — see the full reference below.
All responses return JSON. Successful responses wrap data in a data field:
{
"data": { ... }
}List endpoints return an array in data:
{
"data": [ ... ]
}Paginated endpoints (threads) include pagination fields:
{
"data": [ ... ],
"next_cursor": "eyJsYXN0...",
"has_more": true
}Errors return an error field:
{
"error": "Domain not found"
}| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Bad request — check parameters |
401 |
Invalid or expired API key |
403 |
Insufficient permissions |
404 |
Resource not found |
429 |
Rate limited — slow down |
500 |
Server error |
Domains are custom email domains you own. Register one, add DNS records, verify it, then create inboxes under it.
GET /v1/domains
Permissions: domains:read
Response:
{
"data": [
{
"id": "d1a2b3c4-5678-9012-abcd-ef0123456789",
"name": "example.com",
"status": "verified",
"region": "us-east-1",
"records": [
{
"record": "DKIM",
"name": "resend._domainkey.example",
"type": "TXT",
"value": "p=MIGfMA0...",
"status": "verified",
"ttl": "Auto"
}
],
"inboxes": [
{
"id": "2475ba65-...",
"localPart": "support",
"address": "support@example.com"
}
]
}
]
}POST /v1/domains
Permissions: domains:write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Domain name (e.g. "example.com") |
region |
string |
No | AWS SES region: "us-east-1" (default) or "eu-west-1" |
{
"name": "example.com",
"region": "us-east-1"
}Response: 201 Created
{
"data": {
"id": "d1a2b3c4-...",
"name": "example.com",
"status": "not_started",
"region": "us-east-1"
}
}GET /v1/domains/:domain_id
Permissions: domains:read
Response: Same shape as a single item from List Domains.
POST /v1/domains/:domain_id/verify
Permissions: domains:write
Triggers DNS verification. Call this after adding the required records at your registrar. Use Get Domain Records to see what's needed.
Response:
{
"data": {
"id": "d1a2b3c4-...",
"status": "verified"
}
}GET /v1/domains/:domain_id/records
Permissions: domains:read
Returns DNS records that must be added at your registrar for verification.
Response:
{
"data": [
{
"record": "SPF",
"name": "send.example",
"type": "MX",
"value": "feedback-smtp.us-east-1.amazonses.com",
"priority": 10,
"status": "pending",
"ttl": "Auto"
},
{
"record": "SPF",
"name": "send.example",
"type": "TXT",
"value": "v=spf1 include:amazonses.com ~all",
"status": "pending",
"ttl": "Auto"
},
{
"record": "Receiving",
"name": "example",
"type": "MX",
"value": "inbound-smtp.us-east-1.amazonaws.com",
"priority": 10,
"status": "pending",
"ttl": "Auto"
}
]
}POST /v1/domains— create the domainGET /v1/domains/:id/records— get DNS records to configure- Add records at your registrar (GoDaddy, Cloudflare, Namecheap, etc.)
POST /v1/domains/:id/verify— trigger verificationGET /v1/domains/:id— check status is"verified"
Inboxes are mailboxes that receive and send email. The simplest way to create one is POST /v1/inboxes — the domain is auto-assigned.
POST /v1/inboxes
Permissions: inboxes:write
Create an inbox with auto-domain resolution. No domain setup required — Commune assigns your inbox to an available domain automatically.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
local_part |
string |
Yes | Part before @ (e.g. "support") |
domain_id |
string |
No | Explicit domain. Auto-resolved if omitted. |
name |
string |
No | Display name |
webhook |
object |
No | Webhook config |
{
"local_part": "support"
}Response: 201 Created
{
"data": {
"id": "0c9517a1-...",
"localPart": "support",
"address": "support@agents.example.com",
"createdAt": "2025-03-15T08:26:31.238Z",
"domain_id": "d1a2b3c4-...",
"domain_name": "agents.example.com"
}
}GET /v1/inboxes
Permissions: inboxes:read
Lists all inboxes across all domains for your organization.
GET /v1/domains/:domain_id/inboxes
Permissions: inboxes:read
Response:
{
"data": [
{
"id": "2475ba65-...",
"localPart": "support",
"address": "support@example.com",
"webhook": {
"endpoint": "https://your-app.com/webhook",
"events": ["email.received"]
},
"status": null,
"createdAt": "2025-02-04T08:06:20.382Z"
}
]
}POST /v1/domains/:domain_id/inboxes
Permissions: inboxes:write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
local_part |
string |
Yes | Part before @ (e.g. "support") |
name |
string |
No | Display name (e.g. "Customer Support") |
webhook |
object |
No | {"endpoint": "https://...", "events": ["email.received"]} |
{
"local_part": "support",
"name": "Customer Support",
"webhook": {
"endpoint": "https://your-app.com/webhook",
"events": ["email.received"]
}
}Response: 201 Created
{
"data": {
"id": "2475ba65-...",
"localPart": "support",
"address": "support@example.com"
}
}GET /v1/domains/:domain_id/inboxes/:inbox_id
Permissions: inboxes:read
PUT /v1/domains/:domain_id/inboxes/:inbox_id
Permissions: inboxes:write
Partial update — only provided fields are changed.
Request body:
| Field | Type | Description |
|---|---|---|
local_part |
string |
New local part |
webhook |
object |
New webhook config |
status |
string |
New status |
DELETE /v1/domains/:domain_id/inboxes/:inbox_id
Permissions: inboxes:write
Response:
{
"data": { "ok": true }
}Threads are email conversations — groups of related messages sharing a subject/reply chain. The API uses cursor-based pagination for efficiently browsing large mailboxes.
GET /v1/threads?inbox_id={inbox_id}&limit=20
Permissions: threads:read
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
inbox_id |
string |
Yes* | — | Filter by inbox |
domain_id |
string |
Yes* | — | Filter by domain |
limit |
integer |
No | 20 |
Results per page (1–100) |
cursor |
string |
No | — | Cursor from previous next_cursor |
order |
string |
No | desc |
"desc" (newest first) or "asc" |
*At least one of inbox_id or domain_id is required.
Response:
{
"data": [
{
"thread_id": "thread_e3e16434-7c93-442c-b498-8a073e41bf3b",
"subject": "Order not received",
"last_message_at": "2025-03-15T14:30:00.000Z",
"first_message_at": "2025-03-10T09:15:00.000Z",
"message_count": 4,
"snippet": "Hi, I placed order #4521 five days ago and still...",
"last_direction": "inbound",
"inbox_id": "2475ba65-...",
"domain_id": "d1a2b3c4-...",
"has_attachments": false
}
],
"next_cursor": "eyJsYXN0X21lc3NhZ2VfYXQiOi...",
"has_more": true
}Pagination: Pass next_cursor as cursor to get the next page. When has_more is false, you've reached the end.
# Page 1
curl "$BASE/threads?inbox_id=i_xyz&limit=10"
# Page 2 (use next_cursor from page 1)
curl "$BASE/threads?inbox_id=i_xyz&limit=10&cursor=eyJsYXN0..."GET /v1/threads/:thread_id/messages
Permissions: threads:read
Returns all messages in a thread, oldest first by default.
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
limit |
integer |
No | 50 |
Max messages (1–1000) |
order |
string |
No | asc |
"asc" (chronological) or "desc" |
Response:
{
"data": [
{
"message_id": "msg_abc123",
"thread_id": "thread_e3e16434-...",
"channel": "email",
"direction": "inbound",
"participants": [
{ "role": "sender", "identity": "customer@gmail.com" },
{ "role": "to", "identity": "support@example.com" }
],
"content": "Hi, I placed order #4521 five days ago and haven't received it yet.",
"content_html": "<p>Hi, I placed order #4521...</p>",
"attachments": [],
"created_at": "2025-03-10T09:15:00.000Z",
"metadata": {
"subject": "Order not received",
"created_at": "2025-03-10T09:15:00.000Z",
"domain_id": "d1a2b3c4-...",
"inbox_id": "2475ba65-..."
}
}
]
}POST /v1/messages/send
Permissions: messages:write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
to |
string | string[] |
Yes | Recipient email(s) |
subject |
string |
Yes | Subject line (max 500 chars) |
html |
string |
No* | HTML body |
text |
string |
No* | Plain text body |
from |
string |
No | Full sender address (e.g. "support@example.com") |
inboxId |
string |
No | Inbox to send from (recommended — domain is auto-resolved) |
domainId |
string |
No | Domain to send from (optional — inferred from inboxId) |
cc |
string | string[] |
No | CC recipients |
bcc |
string | string[] |
No | BCC recipients |
reply_to |
string |
No | Reply-to address |
thread_id |
string |
No | Reply in existing thread |
attachments |
string[] |
No | Attachment IDs from upload |
headers |
object |
No | Custom email headers (key-value pairs) |
*At least one of html or text is required.
Sender resolution: The
fromaddress is resolved in this order:
- Explicit
fromfield if providedinboxId→ looks up the inbox's address (domain is inferred automatically)domainIdalone → uses default local part (agent@yourdomain.com)- Falls back to system default — will fail if no default is configured
For reliable sending, always provide
inboxId, or an explicitfromaddress.
Send a new email:
{
"to": "user@example.com",
"subject": "Order Confirmation",
"html": "<h1>Thanks!</h1><p>Your order #1234 is confirmed.</p>",
"inboxId": "2475ba65-..."
}Reply in a thread:
{
"to": "customer@gmail.com",
"subject": "Re: Order not received",
"html": "<p>We're checking with shipping and will update within 24h.</p>",
"thread_id": "thread_e3e16434-...",
"inboxId": "2475ba65-..."
}Send with attachments:
{
"to": "user@example.com",
"subject": "Monthly Report",
"html": "<p>Please see attached.</p>",
"attachments": ["a1b2c3d4e5f6..."],
"inboxId": "2475ba65-..."
}Response: 200 OK
{
"data": {
"id": "re_abc123...",
"thread_id": "thread_e3e16434-...",
"smtp_message_id": "<uuid@yourdomain.com>"
},
"validation": {
"rejected": [
{ "email": "bad@invalid.test", "reason": "No MX records found" }
],
"warnings": [
{ "email": "user@mailinator.com", "reason": "Disposable email domain" }
],
"suppressed": ["bounced@example.com"],
"duration_ms": 42
}
}The
validationfield is only included when there are rejected, warned, or suppressed recipients. If all recipients are valid, onlydatais returned.
GET /v1/messages?inbox_id={inbox_id}
Permissions: messages:read
Query parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
inbox_id |
string |
Yes* | — | Filter by inbox |
domain_id |
string |
Yes* | — | Filter by domain |
sender |
string |
Yes* | — | Filter by sender email |
limit |
integer |
No | 50 |
Max results (1–1000) |
order |
string |
No | desc |
"asc" or "desc" |
before |
string |
No | — | ISO date — messages before this |
after |
string |
No | — | ISO date — messages after this |
*At least one of inbox_id, domain_id, or sender is required.
Response: Same message format as Get Thread Messages.
Upload files first, then reference their IDs when sending emails.
POST /v1/attachments/upload
Permissions: attachments:write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
content |
string |
Yes | Base64-encoded file content |
filename |
string |
Yes | Original filename |
mime_type |
string |
Yes | MIME type |
{
"content": "JVBERi0xLjQKJ...",
"filename": "invoice.pdf",
"mime_type": "application/pdf"
}Response: 201 Created
{
"data": {
"attachment_id": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
"filename": "invoice.pdf",
"mime_type": "application/pdf",
"size": 45230
}
}Note: Attachment IDs are 32-character hex strings (not prefixed).
GET /v1/attachments/:attachment_id
Permissions: attachments:read
Returns metadata (not the file content).
Response:
{
"data": {
"attachment_id": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
"filename": "invoice.pdf",
"mime_type": "application/pdf",
"size": 45230,
"storage_type": "cloudinary",
"source": "email",
"message_id": ""
}
}GET /v1/attachments/:attachment_id/url?expires_in=3600
Permissions: attachments:read
Returns a temporary download URL.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
expires_in |
integer |
3600 |
Seconds until URL expires |
Response:
{
"data": {
"url": "https://res.cloudinary.com/...",
"expires_in": 3600,
"filename": "invoice.pdf",
"mime_type": "application/pdf",
"size": 45230
}
}POST /v1/attachments/upload— upload the file, getattachment_idPOST /v1/messages/send— passattachment_idin theattachmentsarrayGET /v1/attachments/:id/url— later, get a download link
Requests are rate-limited per API key. If you exceed the limit, you'll receive a 429 response:
{
"error": "Rate limit exceeded"
}Back off and retry after a short delay.
Two-phase deletion API with safety mechanisms. No data is deleted until you explicitly confirm with a time-limited token.
Required permission: admin or data:delete
| Scope | What it deletes |
|---|---|
organization |
Everything — messages, attachments, domains, inboxes, users, API keys, sessions, audit logs, the organization itself |
inbox |
All data for a specific inbox — messages, attachments, delivery events, webhook deliveries, suppressions |
messages |
Messages and their attachments only, with optional before date filter |
POST /v1/data/deletion-request
{
"scope": "organization"
}For inbox-scoped deletion:
{
"scope": "inbox",
"inbox_id": "inbox_abc123"
}For messages with a date filter:
{
"scope": "messages",
"before": "2025-01-01T00:00:00Z"
}Response (201):
{
"id": "del_a1b2c3d4e5f6g7h8i9j0",
"scope": "organization",
"status": "pending",
"preview": {
"messages": 1423,
"attachments": 89,
"domains": 2,
"inboxes": 5,
"webhook_deliveries": 312,
"delivery_events": 1891,
"blocked_spam": 47,
"thread_metadata": 203,
"dmarc_reports": 12,
"alerts": 3,
"suppressions": 28,
"spam_reports": 0,
"audit_logs": 4521,
"users": 2,
"api_keys": 3,
"sessions": 1,
"verification_tokens": 0
},
"confirmation_token": "a8f2e1d4c7b6...",
"confirm_by": "2025-06-15T13:00:00.000Z",
"warning": "This will permanently delete ALL data for your organization including users, API keys, and the organization itself. This action cannot be undone."
}The preview shows exactly how many documents will be deleted per collection. The confirmation_token expires after 1 hour.
Review the preview. If you're sure, confirm with the token:
POST /v1/data/deletion-request/del_a1b2c3d4e5f6g7h8i9j0/confirm
{
"confirmation_token": "a8f2e1d4c7b6..."
}Response (200):
{
"id": "del_a1b2c3d4e5f6g7h8i9j0",
"scope": "organization",
"status": "completed",
"preview": { "..." },
"deleted_counts": {
"messages": 1423,
"attachments": 89,
"domains": 2,
"inboxes": 5,
"webhook_deliveries": 312,
"delivery_events": 1891,
"users": 2,
"api_keys": 3
},
"confirmed_at": "2025-06-15T12:05:00.000Z",
"completed_at": "2025-06-15T12:05:03.000Z"
}GET /v1/data/deletion-request/del_a1b2c3d4e5f6g7h8i9j0
- Preview before delete — exact document counts shown before any data is touched
- Confirmation token — HMAC-signed, tied to the specific request, cannot be guessed
- 1-hour expiry — stale requests automatically expire
- One active request per org — no concurrent deletion races
- Permission gated — requires
adminordata:deleteAPI key permission - Audit logged — the deletion event is logged before audit logs are purged