qwicmail
API reference

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

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

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

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

FieldTypeNotes
urlstringRequired. HTTP or HTTPS. We recommend HTTPS in production.
descriptionstringOptional 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:

HeaderValue
X-Qwicmail-EventEvent type, e.g. email.delivered.
X-Qwicmail-Delivery-IdPer-delivery UUID. Re-sent unchanged on retries — use it to dedupe on your side.
X-Qwicmail-SignatureSigned timestamp + body. See Verifying signatures.

Event types

TypeWhen
email.acceptedAPI accepted the submission.
email.queuedMessage persisted to the sender queue.
email.deliveredReceiving MTA returned 2xx on DATA.
email.soft_bouncedTransient SMTP failure (4xx) — will be retried.
email.hard_bouncedPermanent failure (5xx or async DSN). Recipient is added to the suppression list.
email.complainedRecipient marked the message as spam (FBL report). Suppressed.
email.failedExhausted all retries.
email.openedTracking pixel was fetched.
email.clickedA tracked link was followed.
email.unsubscribedOne-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:

  1. Parse the header into t and v1.
  2. Reject if |now - t| exceeds your tolerance (recommended: 5 minutes — protects against replay).
  3. Compute hex(HMAC-SHA256(secret, "<t>." + raw_body)).
  4. Compare with v1 using 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.