REST API
A small surface — three resources (emails, domains, webhooks), one health check, JSON in, JSON out. Drive it from any HTTP client, or use an official SDK.
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). The official SDKs honour Retry-After automatically; if you're calling the API yourself, do the same.
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. |
template_id | string (UUID) | Optional. ID of a stored template (see Templates). When set, html / text are derived from the template; supply variable values in data. |
data | object | Optional. JSON object passed as the Liquid context when template_id is set. Ignored otherwise. |
headers | object | Optional extra headers. From, To, Subject, Message-ID, DKIM-Signature, and Return-Path are reserved. |
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" }
}'const res = await qm.emails.send({
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" },
idempotencyKey: "9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d",
});res = qm.emails.send(
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"},
idempotency_key="9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d",
)res, err := qm.Emails.Send(ctx, &qwicmail.SendRequest{
From: "Receipts <receipts@mail.example.com>",
To: []string{"customer@example.org"},
Subject: "Your receipt #4821",
HTML: "<p>Thanks for your purchase.</p>",
Text: "Thanks for your purchase.",
Headers: map[string]string{"X-Order-ID": "4821"},
IdempotencyKey: "9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d",
})res = qm.emails.send(
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" },
idempotency_key: "9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d",
)$res = $qm->emails->send([
"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"],
"idempotency_key" => "9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d",
]);SendResponse res = qm.emails().send(SendRequest.builder()
.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.")
.header("X-Order-ID", "4821")
.idempotencyKey("9e8d2c1b-6f4a-4d31-9b5c-7e2f1a3b4c5d")
.build());Response — 202 Accepted
{
"id": "1f0c2c1a-e0c7-4a8b-9c0a-...",
"message_ids": ["8a3f1f5b-d2c5-4e7f-8a1b-..."],
"rejected": [],
"replayed": false
}
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 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.
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. The full payload catalogue, retry schedule, and signature-verification recipes (in every language with an SDK) live on the Webhooks guide. The endpoint-management API is here:
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"
}
Store the secret immediately — it's required to verify event signatures and 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.
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.