Documentation
Public API & MCP server
Build agents, CLIs, and back-office integrations on top of qmailing. PLUS and above unlocks Bearer-token auth on every public endpoint plus a ready-made MCP server you can drop into Claude Desktop, Cursor, or any other compatible client.
Getting started
- 1. Upgrade to PLUS — the public API is gated on
PLUS,PRO,PRO_PLUS, orPRO_MAX. FREE callers receive402 Payment Requiredregardless of token. - 2. Issue a token — go to Settings → Developers, click New token, pick the scopes you need, and copy the
qm_live_…value when shown. Tokens appear once. - 3. Make your first call:
curl https://qmailing.com/api/v1/pub/mailboxes \ -H "Authorization: Bearer qm_live_your_token_here"
Authentication
Every public-API call carries an Authorization: Bearer qm_live_<…> header. The token format is namespaced (qm_live_ followed by 32 random characters) so leaks in logs and repos are obvious.
Tokens are stored hashed (SHA-256). The plaintext is shown exactly once at issue time and never again — if you lose it, mint a fresh one. Each request triggers a constant-time hash comparison; last_used_at updates on success so the developers UI can surface stale credentials.
apiAccess on every signed request — there's no need to revoke tokens individually when you switch plans.Scopes
Each token carries one or more scope strings. Endpoints declare the scopes they need; missing scopes return 403 InsufficientScope. The wildcard (*) grants everything — only enable when you really need it.
| Scope | Allows |
|---|---|
| mailboxes:read | List and inspect mailboxes |
| mailboxes:write | Create, modify, delete mailboxes |
| domains:read | List domains and DNS records |
| domains:write | Add, verify, delete custom domains (FE-only in v1) |
| email:read | List and inspect mailbox messages |
| email:send | Send outbound mail through the API |
| webhooks:read | List webhook endpoints |
| webhooks:write | Register, list, and revoke webhook endpoints |
| * | Wildcard — grants everything. Use sparingly. |
Mailboxes
List
GET /api/v1/pub/mailboxes
# scope: mailboxes:readGet one
GET /api/v1/pub/mailboxes/{id}
# scope: mailboxes:readCreate
POST /api/v1/pub/mailboxes
Content-Type: application/json
# scope: mailboxes:write
{
"localPart": "support",
"domain": "yourbrand.com",
"displayName": "Support team",
"forwardTo": "ops@yourbrand.com"
}The domain field is optional — leave it out and the mailbox is created on qmailing.com. On a custom domain the domain must be both claimed AND fullyVerified; a not-yet-ready domain returns 400 rather than silently creating a non-routing mailbox.
Domains & DNS
List custom domains
GET /api/v1/pub/domains
# scope: domains:readDNS-records checklist
GET /api/v1/pub/domains/{id}/dns-records
# scope: domains:readReturns the full 8-record checklist (challenge TXT, MX, SPF, three DKIM CNAMEs, DMARC, optional _amazonses) with a per-row status of PENDING / FOUND / MISMATCH / NOT_REQUIRED. The agent can use this to tell the user exactly what to publish at their DNS provider.
Read mailbox contents and send outbound mail. Folder paging matches the FE's internal API — agents porting between cookie-JWT and Bearer auth see no shape change.
List by folder
GET /api/v1/pub/email?folder=INBOX&offset=0&limit=25
# scope: email:read
# Optional query params:
# mailboxId filter to one mailbox (omit for all-mailboxes view)
# folder INBOX | SENT | DRAFTS | TRASH | STARRED | SPAM
# offset 0-based index, default 0
# limit 1..100, default 25folder defaults to INBOX; allowed values: INBOX, SENT, DRAFTS, TRASH, STARRED, SPAM. mailboxId is optional — omitting it lists across every mailbox the caller owns. Pagination via offset + limit; limit is clamped to the range 1–100 (default 25).
Get one
GET /api/v1/pub/email/{id}
# scope: email:readReturns the full EmailDetailDto: HTML / text body, label list, attachment metadata. Attachment payloads are NOT inlined — fetch each with GET /{emailId}/attachments/{index} below, which streams raw bytes with the original filename + content-type.
Download an attachment
GET /api/v1/pub/email/{emailId}/attachments/{index}
# scope: email:read
# response: streamed bytes, Content-Type from the attachmentStreams the raw attachment bytes for the message (zero-based index inside the email's attachment list). Mirrors the session-based download endpoint shape: pass-through Content-Type, RFC 6266 Content-Disposition with the original filename (UTF-8 percent-encoded for Unicode). MCP agents calling via @qmailing/mcp-server use the qmailing_get_attachment tool, which base64-encodes the bytes inline (capped at 5 MiB).
Send
Compose endpoint. multipart/form-data body with one command JSON part carrying recipients / subject / body and zero-or-more attachments file parts:
POST /api/v1/pub/email/send
Content-Type: multipart/form-data
# scope: email:send
--boundary
Content-Disposition: form-data; name="command"
Content-Type: application/json
{
"mailboxId": "11111111-2222-3333-4444-555555555555",
"to": ["alice@example.com"],
"cc": [],
"bcc": [],
"subject": "Order #1428 confirmed",
"bodyText": "Plain-text body",
"bodyHtml": "<p>Rich body</p>",
"replyToId": null
}
--boundary
Content-Disposition: form-data; name="attachments"; filename="receipt.pdf"
Content-Type: application/pdf
<binary bytes>
--boundary--command JSON shape:
{
"mailboxId": "uuid (required) — must be SES-verified",
"to": "string[] (required, max 50)",
"cc": "string[] (optional, max 50)",
"bcc": "string[] (optional, max 50)",
"subject": "string (max 998 chars)",
"bodyText": "string — plain-text body",
"bodyHtml": "string — HTML body",
"replyToId": "uuid | null — set for in-thread replies"
}Each call counts against the per-plan daily send limit, the per-mailbox SES sending quota, and the user's storage quota for the resulting SENT copy. Recipients are validated as RFC 5322 addresses; bad ones return 400 with the offending address. The mailbox referenced in mailboxId must be SES-verified — otherwise 409 MailboxNotVerified with a hint pointing back at the domains page.
multipart/form-data on the way out so the wire shape stays identical.filename on the command part is optional — Node fetch auto-stamps filename="blob" on every Blob, browsers and Node clients can pass command.json, and well-behaved curl --form clients can omit it entirely. All three shapes parse the same; only Content-Type: application/json on the part bytes is meaningful.Scopes: email:read for list + get-one, email:send for compose.
Webhooks
Register an HTTPS endpoint and QMailing POSTs a signed JSON envelope to it whenever one of the subscribed events fires. Delivery is live — the worker polls a per-event queue every 10s, signs each request with HMAC-SHA256, and retries failures on an exponential backoff (1m / 5m / 15m / 1h / 6h) before parking the row in the DLQ for manual review.
Register an endpoint
POST /api/v1/pub/webhooks
Content-Type: application/json
# scope: webhooks:write
{
"url": "https://your-server.example.com/qmailing-events",
"label": "Production listener",
"events": ["email.received", "email.bounced", "domain.verified"]
}
# Response (201):
{
"endpoint": {
"id": "uuid",
"url": "https://your-server.example.com/qmailing-events",
"label": "Production listener",
"events": ["email.received", "email.bounced", "domain.verified"],
"secretPrefix": "whk_AbCd",
"createdAt": "2026-05-04T20:30:00Z",
"active": true
},
"plaintext": "whk_AbCd…<32 random chars>" // shown ONCE
}url must be an HTTPS endpoint (HTTP allowed only for localhost dev). The host is checked against an SSRF allow-list — private / loopback / cloud-metadata addresses are rejected at registration. Response carries plaintext exactly once; store it server-side and use it to verify HMAC signatures on inbound deliveries.
Event catalogue
| Event | Fires when |
|---|---|
| email.received | An inbound email lands in one of your mailboxes. |
| email.sent | An outbound email has been accepted by SES. |
| email.bounced | An outbound email bounced (hard or soft). |
| domain.verified | A custom domain finished DNS verification end-to-end. |
List endpoints
GET /api/v1/pub/webhooks
# scopes: webhooks:read OR webhooks:writeReturns active and revoked endpoints, newest first. Read access works with either webhooks:read or webhooks:write — letting an inspector token avoid the broader write scope.
Delivery wire shape
Every delivery is a single POST to your endpoint with Content-Type: application/json. The body is the event envelope — an event name, an occurred_at ISO timestamp, and a data block whose shape depends on the event (see the table above). Three headers carry routing + verification metadata:
POST https://your-server.example.com/qmailing-events
Content-Type: application/json
User-Agent: qmailing-webhook/1 (+https://qmailing.com)
X-Qmailing-Event: email.received
X-Qmailing-Delivery: 5f3b2a91-7c4d-4d52-9c3e-aa1bcd8a4f12
X-Qmailing-Signature: t=1685120800,v1=abc123def…
{
"event": "email.received",
"occurred_at": "2026-05-23T19:46:40Z",
"data": {
"email_id": "uuid",
"mailbox_id": "uuid",
"from": "alice@example.com",
"subject": "Hello",
"preview_text": "Just checking in…"
}
}Signature scheme
# Receiver side (Node example)
const crypto = require("node:crypto");
function verify(headers, body, secret) {
const sig = headers["x-qmailing-signature"]; // "t=1685120800,v1=abc123…"
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(sig);
if (!m) return false;
const ts = Number(m[1]);
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-min replay window
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}Reject the request if the timestamp is more than 5 minutes old (replay-window) or if the recomputed HMAC doesn't match the v1 hex — the BE-side signer mirrors Stripe / GitHub conventions exactly so any of the standard verification libraries will work off the shelf.
Retry policy
Non-2xx responses (or transport errors — DNS, TLS, connection refused, 10s timeout) increment attempt_count and push next_attempt_at by the next slot in 1m / 5m / 15m / 1h / 6h. After the fifth failure the delivery transitions to FAILED (DLQ) — visible on the /settings/developers page where the developer can retry it manually. Idempotency: every retry carries the same X-Qmailing-Delivery UUID — receivers deduplicate on that header.
List recent deliveries
GET /api/v1/pub/webhooks/{endpointId}/deliveries?limit=100
# scopes: webhooks:read OR webhooks:writeReturns the most recent limit deliveries for a single endpoint (default 100, max 100), newest first. Each row carries status, attempt count, last HTTP code, last error string, and the original payload JSON — drives the history view on the developer dashboard.
Send a test delivery
POST /api/v1/pub/webhooks/{endpointId}/test
# scope: webhooks:write
# Response 202: WebhookDeliveryDto (enqueued, picked up next worker tick)Fires a synthetic webhook.test event at the endpoint — same dispatch path as real events, useful for verifying signature + TLS before subscribing to live traffic.
Retry a delivery
POST /api/v1/pub/webhooks/deliveries/{deliveryId}/retry
# scope: webhooks:write
# Response 200: WebhookDeliveryDto (status=PENDING, attempt=MAX-1)Manually re-queues a delivery row. Cannot retry an already-DELIVERED row (409). The retry gives exactly one additional attempt (attempt counter resets to MAX-1) so a persistently broken endpoint can't be looped indefinitely by repeated retry clicks.
Delete a delivery from history
DELETE /api/v1/pub/webhooks/deliveries/{deliveryId}
# scope: webhooks:write
# Response: 204 No ContentHard-delete a delivery row from the history table. Compliance audit log captures the deletion event, so traceability isn't lost when the row is.
Revoke an endpoint
DELETE /api/v1/pub/webhooks/{id}
# scope: webhooks:write
# Response: 204 No ContentIdempotent — revoking an already-revoked endpoint succeeds quietly. The row is preserved (revoked-at timestamp set) so audit logs keep their references.
409 WebhookEndpointLimitExceeded / 400 InvalidWebhookEvent.Errors
Every non-2xx response is an RFC-7807 ProblemDetail with a stable code field your agent can branch on:
| HTTP | code | When |
|---|---|---|
| 400 | InvalidApiTokenScope | Unknown scope string at create time. |
| 400 | InvalidEmailAddress | One of the recipients failed RFC 5322 syntax checks. |
| 400 | InvalidWebhookEvent | Registered events list contains an unknown event name. |
| 401 | — | Bearer header missing, malformed, or token revoked / expired. |
| 402 | PlanFeatureRequired | Caller is on a plan that does not include API access — upgrade. |
| 403 | InsufficientScope | Token does not carry any of the required scopes. |
| 404 | ApiTokenNotFound | Revoke target does not exist or belongs to another account. |
| 409 | MailboxNotVerified | Send target's domain isn't fully verified yet — finish DNS in /settings/domains. |
| 409 | ApiTokenLimitExceeded | Account holds the maximum (20) active tokens; revoke one first. |
| 409 | WebhookEndpointLimitExceeded | Account already holds 10 active webhook endpoints; revoke one first. |
| 429 | RateLimitExceeded | Token exceeded 300 requests per minute. Retry-After in seconds. |
Rate limits
Each token gets its own bucket of 300 requests per minute. Multi-agent setups (CLI + cron + IDE plugin) using different tokens for the same account run independently — they never fight for one shared bucket. 429 RateLimitExceeded includes a Retry-After header in seconds.
MCP server
The @qmailing/mcp-server package runs locally and exposes the public API as Model Context Protocol tools. Drop this into Claude Desktop, Cursor, Continue, or any MCP-compatible client.
@qmailing/mcp-server@0.2.0) if you don't want auto-upgrades.{
"mcpServers": {
"qmailing": {
"command": "npx",
"args": ["-y", "@qmailing/mcp-server"],
"env": {
"QMAILING_API_TOKEN": "qm_live_your_token_here"
}
}
}
}Bundled tools: qmailing_list_mailboxes, qmailing_get_mailbox, qmailing_create_mailbox, qmailing_list_domains, qmailing_get_dns_records, qmailing_list_emails, qmailing_get_email, qmailing_get_attachment, qmailing_send_email, qmailing_register_webhook, qmailing_list_webhooks, qmailing_delete_webhook. The catalogue grows as new endpoints land.