QMailing
ProductPricingAPI & MCP
Sign in

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.

Get started Manage tokens

On this page

  • 1. Getting started
  • 2. Authentication
  • 3. Scopes
  • 4. Mailboxes
  • 5. Domains & DNS
  • 6. Email
  • 7. Webhooks
  • 8. Errors
  • 9. Rate limits
  • 10. MCP server

Getting started

  1. 1. Upgrade to PLUS — the public API is gated on PLUS, PRO, PRO_PLUS, or PRO_MAX. FREE callers receive 402 Payment Required regardless of token.
  2. 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. 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.

Plan downgrades disable existing tokens immediately. The API re-checks 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.

ScopeAllows
mailboxes:readList and inspect mailboxes
mailboxes:writeCreate, modify, delete mailboxes
domains:readList domains and DNS records
domains:writeAdd, verify, delete custom domains (FE-only in v1)
email:readList and inspect mailbox messages
email:sendSend outbound mail through the API
webhooks:readList webhook endpoints
webhooks:writeRegister, list, and revoke webhook endpoints
*Wildcard — grants everything. Use sparingly.

Mailboxes

List

GET /api/v1/pub/mailboxes
# scope: mailboxes:read

Get one

GET /api/v1/pub/mailboxes/{id}
# scope: mailboxes:read

Create

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.

Mailbox deletion is intentionally not exposed in v1. It records a Terms-of-Service consent and burns a per-account lifetime quota slot — both belong to a human pressing a button after reading the modal, not to an unattended agent. Use the FE.

Domains & DNS

List custom domains

GET /api/v1/pub/domains
# scope: domains:read

DNS-records checklist

GET /api/v1/pub/domains/{id}/dns-records
# scope: domains:read

Returns 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.

Email

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 25

folder 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:read

Returns 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 attachment

Streams 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.

Agents that prefer JSON over multipart can use the bundled MCP tool — it base64-encodes attachments inline, then re-packs into multipart/form-data on the way out so the wire shape stays identical.
The 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

EventFires when
email.receivedAn inbound email lands in one of your mailboxes.
email.sentAn outbound email has been accepted by SES.
email.bouncedAn outbound email bounced (hard or soft).
domain.verifiedA custom domain finished DNS verification end-to-end.

List endpoints

GET /api/v1/pub/webhooks
# scopes: webhooks:read OR webhooks:write

Returns 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:write

Returns 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 Content

Hard-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 Content

Idempotent — revoking an already-revoked endpoint succeeds quietly. The row is preserved (revoked-at timestamp set) so audit logs keep their references.

Cap: 10 active endpoints per account, 32 events per endpoint. Both are enforced at registration with 409 WebhookEndpointLimitExceeded / 400 InvalidWebhookEvent.

Errors

Every non-2xx response is an RFC-7807 ProblemDetail with a stable code field your agent can branch on:

HTTPcodeWhen
400InvalidApiTokenScopeUnknown scope string at create time.
400InvalidEmailAddressOne of the recipients failed RFC 5322 syntax checks.
400InvalidWebhookEventRegistered events list contains an unknown event name.
401—Bearer header missing, malformed, or token revoked / expired.
402PlanFeatureRequiredCaller is on a plan that does not include API access — upgrade.
403InsufficientScopeToken does not carry any of the required scopes.
404ApiTokenNotFoundRevoke target does not exist or belongs to another account.
409MailboxNotVerifiedSend target's domain isn't fully verified yet — finish DNS in /settings/domains.
409ApiTokenLimitExceededAccount holds the maximum (20) active tokens; revoke one first.
409WebhookEndpointLimitExceededAccount already holds 10 active webhook endpoints; revoke one first.
429RateLimitExceededToken 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.

The wire shape is stable — but new tools may land in any 0.x release. Pin a specific version (@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.

Found a bug or have a question? Tokens, scopes, plan limits and rate-limit counters are all observable from your developers page.

QMailing

Create mailboxes, not accounts.

Product

  • Everything you need
  • Pricing
  • Compare
  • Use cases
  • API & MCP

Legal

  • Privacy Policy
  • Terms of Service
  • Refund Policy
  • Data Deletion

Support

  • Contact support
© 2026 QMailing. All rights reserved.