qwicmail
qwicmail docs

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/json for 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 to field.

Authentication

Every authenticated request carries a bearer token in the Authorization header:

HTTP 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:

ScopeEndpoints
emails:sendPOST /emails
domains:writePOST /domains, POST /domains/{id}/verify, DELETE /domains/{id}
webhooks:writePOST /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 response
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:

JSON
{ "error": "<machine_code>", "message": "<human-readable>" }

The machine codes you might encounter:

HTTPCodeMeaning
400invalid_jsonThe body did not parse as JSON.
400validation_failedA required field is missing or invalid. See message.
400invalid_idA path parameter is not a UUID.
400read_body_failedThe body could not be read.
401missing_or_malformed_authorizationNo or malformed Authorization header.
401invalid_api_keyThe key is unknown or revoked.
403scope_requiredThe key lacks the required scope.
403tenant_not_activeYour tenant is suspended, in review, or otherwise not in a sending state.
404domain_not_found / webhook_not_foundResource doesn't exist (or isn't yours).
409domain_exists / webhook_existsThat resource is already registered.
413request_too_largeRequest body exceeded 5 MiB.
415unsupported_media_typeSet Content-Type: application/json.
422domain_not_verifiedThe from address's domain is not a verified sending domain for your tenant.
422all_recipients_suppressedEvery recipient is on the suppression list; nothing was queued.
422verification_failedDomain verification check failed; check is included in the body.
429rate_limitedSee Rate limiting.
500internal_errorUnexpected server-side failure. Safe to retry with backoff.

Emails

POST /emails — Send a transactional message

Scope: emails:send.

Request body

FieldTypeNotes
fromstringRequired. "Name <addr@your.domain>" or bare addr@your.domain. The domain must be verified for your tenant.
tostring or array of stringsRequired. Single address or array. Max 50 per call.
subjectstringRequired.
htmlstringHTML body. At least one of html or text is required.
textstringPlain-text body. Sent alongside html as a multipart alternative when both are present.
template_idstring (UUID)Optional. ID of a stored template (see Templates). When set, html / text are derived from the template; supply variable values in data.
dataobjectOptional. JSON object passed as the Liquid context when template_id is set. Ignored otherwise.
headersobjectOptional 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

JSON
{
  "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:

JSON
"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 from domain.
  • Envelope-from is a VERP address on bounces.qwicmail.com so 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

FieldTypeNotes
domainstringRequired. Lowercase, no trailing dot. mail.example.com.
selectorstringOptional DKIM selector. Defaults to qm + current YYYYMM (e.g. qm202605).

Response — 201 Created

JSON
{
  "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

FieldTypeNotes
urlstringRequired. HTTP or HTTPS. We recommend HTTPS in production.
descriptionstringOptional free-text label, e.g. "production event sink".

Response — 201 Created

JSON
{
  "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.