REST API.
A small surface — three resources (emails, domains, webhooks), one health check, JSON in, JSON out. Driven from any HTTP client; official SDKs (TypeScript, Python, Go, Ruby) are on the Phase 5 roadmap.
Conventions
- Base URL.
https://api.qwicmail.com - Content type.
application/jsonfor every request and response. - Field naming.
snake_case. - Times. RFC 3339 in UTC (e.g.
2026-05-26T10:15:42Z). - IDs. UUIDs, lowercase, hyphenated.
- Request size. 5 MiB max per call.
- Per-call recipients. 50 max in the
tofield.
Authentication
Every authenticated request carries a bearer token in the
Authorization header:
Authorization: Bearer qm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
API keys are minted in the customer portal (Settings → API keys → New key) and are scoped — a key can only call endpoints whose scope it was granted:
| Scope | Endpoints |
|---|---|
emails:send | POST /emails |
domains:write | POST /domains, POST /domains/{id}/verify, DELETE /domains/{id} |
webhooks:write | POST /webhooks, DELETE /webhooks/{id} |
Read endpoints (GET /domains, GET /webhooks …)
don't require a specific scope beyond a valid, non-revoked key. A request
whose tenant is suspended or pending verification returns
403 tenant_not_active. Calls with a missing, malformed, or
revoked token return 401.
Rate limiting
Limits are enforced per API key. When you exceed yours the response is:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{ "error": "rate_limited", "message": "API key has exceeded its allowed request rate", "retry_after_secs": 1 }
Respect Retry-After. Per-key limits are tuned to the tenant's
sending tier and rise as the tier escalates (1k → 10k → uncapped over the
first 30 days). If you have a legitimate burst requirement, talk to support
rather than retrying in a tight loop.
Idempotency
POST /emails accepts an Idempotency-Key header
(up to 200 characters; a UUID is fine). The first call with a given
(tenant, key) pair is processed normally. Subsequent calls with the
same key return the original submission's IDs and the response
carries "replayed": true. The body of the replayed call is
ignored — the original is the source of truth.
Use this if your code retries network errors that may have actually succeeded server-side. Without it, a retry could send the same email twice.
Errors
Every error response has the shape:
{ "error": "<machine_code>", "message": "<human-readable>" }
The machine codes you might encounter:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body did not parse as JSON. |
| 400 | validation_failed | A required field is missing or invalid. See message. |
| 400 | invalid_id | A path parameter is not a UUID. |
| 400 | read_body_failed | The body could not be read. |
| 401 | missing_or_malformed_authorization | No or malformed Authorization header. |
| 401 | invalid_api_key | The key is unknown or revoked. |
| 403 | scope_required | The key lacks the required scope. |
| 403 | tenant_not_active | Your tenant is suspended, in review, or otherwise not in a sending state. |
| 404 | domain_not_found / webhook_not_found | Resource doesn't exist (or isn't yours). |
| 409 | domain_exists / webhook_exists | That resource is already registered. |
| 413 | request_too_large | Request body exceeded 5 MiB. |
| 415 | unsupported_media_type | Set Content-Type: application/json. |
| 422 | domain_not_verified | The from address's domain is not a verified sending domain for your tenant. |
| 422 | all_recipients_suppressed | Every recipient is on the suppression list; nothing was queued. |
| 422 | verification_failed | Domain verification check failed; check is included in the body. |
| 429 | rate_limited | See Rate limiting. |
| 500 | internal_error | Unexpected server-side failure. Safe to retry with backoff. |
Emails
POST /emails — Send a transactional message
Scope: emails:send.
Request body
| Field | Type | Notes |
|---|---|---|
from | string | Required. "Name <addr@your.domain>" or bare addr@your.domain. The domain must be verified for your tenant. |
to | string or array of strings | Required. Single address or array. Max 50 per call. |
subject | string | Required. |
html | string | HTML body. At least one of html or text is required. |
text | string | Plain-text body. Sent alongside html as a multipart alternative when both are present. |
headers | object | Optional extra headers. From, To, Subject, Message-ID, DKIM-Signature, and Return-Path are reserved and will be ignored if supplied. |
Headers
Authorization: Bearer …— required.Content-Type: application/json— required.Idempotency-Key: <your-key>— optional; see Idempotency.
Example
curl https://api.qwicmail.com/emails \
-H "Authorization: Bearer $QWICMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d" \
-d '{
"from": "Receipts <receipts@mail.example.com>",
"to": ["customer@example.org"],
"subject": "Your receipt #4821",
"html": "<p>Thanks for your purchase.</p>",
"text": "Thanks for your purchase.",
"headers": { "X-Order-ID": "4821" }
}'
Response — 202 Accepted
{
"id": "1f0c2c1a-e0c7-4a8b-9c0a-...", // submission id
"message_ids": ["8a3f1f5b-d2c5-4e7f-8a1b-..."], // one per accepted recipient
"rejected": [], // see below
"replayed": false // true if Idempotency-Key matched
}
Each message_id is the same value set as the SMTP
Message-ID header on the outgoing wire and quoted in every
webhook event the message produces. The rejected array lists
recipients that were dropped because they're on your tenant suppression
list (or the platform's global hard-bounce list); they're not retried.
Format:
"rejected": [
{ "to": "blocked@example.org", "reason": "hard_bounce" }
]
Reason codes: hard_bounce, complaint,
unsubscribe, manual.
What gets done for you
- DKIM signed with the key for the
fromdomain. - Envelope-from is a VERP address on
bounces.qwicmail.comso bounces can be correlated back to the message — that's why the recipient sees our bounces domain in DSNs, not yours. - Tracking pixels and click redirects are inserted into the HTML when tracking is enabled for your tenant.
Domains
A domain is a sending identity owned by your tenant. To send
mail from alerts@mail.example.com, register
mail.example.com, publish the DNS records, then verify.
POST /domains — Register a new sending domain
Scope: domains:write.
Request body
| Field | Type | Notes |
|---|---|---|
domain | string | Required. Lowercase, no trailing dot. mail.example.com. |
selector | string | Optional DKIM selector. Defaults to qm + current YYYYMM (e.g. qm202605). |
Response — 201 Created
{
"id": "f1c2d3e4-...",
"domain": "mail.example.com",
"state": "pending",
"dkim_selector": "qm202605",
"dkim_public_key_b64": "MIIBIjANBgkqhki...",
"created_at": "2026-05-26T09:00:00Z",
"records": [
{
"name": "qm202605._domainkey.mail.example.com",
"type": "TXT",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
"required": true,
"purpose": "DKIM signing key — required for outbound mail from this domain to verify."
},
{
"name": "mail.example.com",
"type": "TXT",
"value": "v=spf1 include:qwicmail.com ~all",
"required": false,
"purpose": "SPF — recommended. Merge include:qwicmail.com into your existing record if you already publish one."
},
{
"name": "_dmarc.mail.example.com",
"type": "TXT",
"value": "v=DMARC1; p=none; rua=mailto:dmarc-reports@qwicmail.com",
"required": false,
"purpose": "DMARC — recommended monitoring-mode policy."
}
]
}
GET /domains — List all sending domains
Returns { "domains": [ ... ] } in the same shape as the create response.
GET /domains/{id} — Fetch a single domain
POST /domains/{id}/verify — Run the verification check
Scope: domains:write.
Queries your authoritative DNS for the DKIM TXT record (required) and
the SPF + DMARC records (advisory). On success the domain transitions to
verified. On failure it transitions to failed
with the first failing record's reason and the response is
422 verification_failed with both domain and
check populated so you can render diagnostics. Either way,
you can re-run verification any time — publish the records, retry the call.
Successful response — 200 OK:
{
"domain": { ... domainResponse with state=verified ... },
"check": { "pass": true, "records": [ ... ] }
}
DELETE /domains/{id} — Revoke a domain
Scope: domains:write.
Soft-delete: the domain moves to the revoked state and can no
longer be used as a sender. Existing messages keep flowing through;
only new sends are blocked.
Webhooks
Webhooks deliver delivery, bounce, complaint, and open events to an HTTPS endpoint of your choosing. One endpoint receives events for all your sending domains; you can register multiple if you want fan-out (e.g. one for production analytics, one for an internal Slack notifier).
POST /webhooks — Register an endpoint
Scope: webhooks:write.
Request body
| Field | Type | Notes |
|---|---|---|
url | string | Required. HTTP or HTTPS. We recommend HTTPS in production. |
description | string | Optional free-text label, e.g. "production event sink". |
Response — 201 Created
{
"id": "a1b2c3d4-...",
"url": "https://example.com/hooks/qwicmail",
"description": "production event sink",
"created_at": "2026-05-26T09:05:11Z",
"secret": "base64-encoded-secret" // shown exactly once
}
Store the secret immediately — it's required to verify event
signatures and it is not returned by any other endpoint. If you
lose it, delete the webhook and register a new one.
GET /webhooks — List endpoints
GET /webhooks/{id} — Fetch one endpoint
Neither list nor get returns the secret.
DELETE /webhooks/{id} — Remove an endpoint
Scope: webhooks:write. Returns 204 No Content. New events will no longer be dispatched to this URL; in-flight retries for prior events are abandoned.
Webhook payloads
Every event is POSTed as JSON with three headers we set on every request:
| Header | Value |
|---|---|
X-Qwicmail-Event | Event type, e.g. email.delivered. |
X-Qwicmail-Delivery-Id | Per-delivery UUID. Re-sent unchanged on retries — use it to dedupe on your side. |
X-Qwicmail-Signature | Signed timestamp + body. See Verifying signatures. |
Event types
| Type | When |
|---|---|
email.accepted | API accepted the submission. |
email.queued | Message persisted to the sender queue. |
email.delivered | Receiving MTA returned 2xx on DATA. |
email.soft_bounced | Transient SMTP failure (4xx) — will be retried. |
email.hard_bounced | Permanent failure (5xx or async DSN). Recipient is added to the suppression list. |
email.complained | Recipient marked the message as spam (FBL report). Suppressed. |
email.failed | Exhausted all retries. |
email.opened | Tracking pixel was fetched. |
email.clicked | A tracked link was followed. |
email.unsubscribed | One-click List-Unsubscribe was actioned. |
Payload shape
{
"id": "f47ac10b-58cc-...", // event id
"type": "email.delivered",
"occurred_at": "2026-05-26T09:07:42.123Z",
"message_id": "8a3f1f5b-d2c5-...", // the message this event is about
"smtp_code": 250, // present on delivery/bounce events
"detail": { ... } // type-specific detail (see below)
}
detail shapes by event type:
hard_bounced/soft_bounced—{ "source": "async_dsn", "status": "5.1.1", "diagnostic": "..." }complained—{ "source": "arf", "feedback_type": "abuse" }opened—{ "source": "pixel", "user_agent": "...", "ip": "..." }clicked—{ "source": "redirect", "tracked_link_id": "...", "url": "..." }unsubscribed—{ "source": "list-unsubscribe", "method": "one-click" }failed—{ "reason": "max_attempts", "smtp_message": "..." }
Verifying signatures
The X-Qwicmail-Signature header is a comma-separated list of
a Unix timestamp and a hex-encoded HMAC-SHA256:
X-Qwicmail-Signature: t=1748246862,v1=8e5a3...c91d
The signed payload is "<timestamp>.<raw-body>". To verify:
- Parse the header into
tandv1. - Reject if
|now - t|exceeds your tolerance (recommended: 5 minutes — protects against replay). - Compute
hex(HMAC-SHA256(secret, "<t>." + raw_body)). - Compare with
v1using a constant-time comparator.
Use the raw bytes of the request body — re-marshalling JSON will change whitespace and break the signature. Implementations exist in any language; the shape is intentionally close to Stripe's so reference code translates with minimal change.
Retries
Non-2xx responses (or transport errors) are retried with exponential
backoff: 30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h — eight attempts spanning
about 16 hours. After the final attempt the delivery is marked
failed and not retried. Receivers should aim for < 5s
response time; we time individual POSTs out at 10s.
Health
GET /healthz
Unauthenticated. Returns 200 { "status": "ok" } when the API
process can reach the database, or 503 { "status": "db_unreachable" }
when it can't. Intended for liveness probes — don't poll it from customer
code as a deliverability signal.