API Conventions
Behaviour that applies across every endpoint — stated once here so it is not repeated in each endpoint's docs.
Base URL
All API endpoints share a single base URL:
https://api.quolle.comEvery path in this documentation is relative to this base. There is no versioning prefix in the URL — breaking changes ship as new resources, not new versions.
Authentication
All public API requests require an API key in the Authorization header:
Authorization: Bearer qle_your_api_keyKeys start with qle_ and are created in the dashboard under API Keys. See the Authentication page for setup and security guidance.
Request format
All request bodies must be JSON. Set the content type on every POST request:
Content-Type: application/jsonRequests without a body (GET, DELETE) do not need a Content-Type header.
Response format
All responses are JSON. The shape depends on whether the request succeeded.
Success
The response body is the resource or result directly — no wrapper envelope:
// POST /v1/emails/send — 200
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "Email queued successfully"
}Error
Errors always include a code and an error string. code is a stable identifier to branch on; error is a sentence for a person to read and may be reworded, so never match on it. Many errors carry additional fields alongside both:
// 402 — monthly limit reached
{
"code": "monthly_quota_exceeded",
"error": "Monthly limit reached",
"limit": 3000,
"used": 3000,
"plan": "Starter"
}See Errors → Error codes for every code and what to do about each one.
Validation errors (missing or wrong-typed fields) return a different shape — see Errors → 400 for the full Zod error structure.
Dates and times
All timestamps are ISO 8601 strings in UTC, ending in Z:
2026-07-05T12:00:00.000ZWhen submitting a datetime (e.g. scheduledAt), use the same format. The API rejects non-ISO strings and datetimes that are not in the future.
Idempotency
POST /v1/emails/send, POST /v1/emails/batch, POST /v1/emails/receipt and POST /v1/broadcasts support an optional Idempotency-Key header. If you include one and the request succeeds, the response is cached for 24 hours under that key. A duplicate request with the same key, method and path returns the cached response immediately with an Idempotency-Replay: true response header — nothing is sent again.
Idempotency-Key: order_invoice_12345Two cases answer 409 rather than replaying, and they ask for different things:
| Code | Means | What to do |
|---|---|---|
concurrent_idempotent_request | The first request with this key is still running. | Wait a few seconds and retry unchanged. |
invalid_idempotent_request | This key was already used with a different request body. | Use a new key, or send the original request. The body is fingerprinted, so a key only ever answers for the request it was first used with. |
Where the unsubscribe link goes
Every marketing message carries one-click List-Unsubscribe headers, and a link is appended to the end of the body for you. You do not have to do anything.
You can place it yourself instead, with {{unsubscribeUrl}} anywhere in your HTML. Do that and the appended footer is left off — two unsubscribe links in one message reads as a mistake.
<a href="{{unsubscribeUrl}}">Unsubscribe</a>{{preferencesUrl}} is also available, sending the reader to a page where they can turn off one topic rather than everything. It falls back to the unsubscribe link when the message has no topic.
Pagination
List endpoints that return multiple records use a consistent pagination envelope:
{
"data": [ /* array of records */ ],
"pagination": {
"total": 42,
"page": 1,
"limit": 20,
"pages": 3
}
}Query parameters: page (default 1) and limit. pages is Math.ceil(total / limit).
Defaults differ by endpoint, because the records do:
| Endpoint | Default | Max |
|---|---|---|
GET /v1/logs | 20 | 100 |
GET /v1/contacts/:listId | 100 | 500 |
total is always the true count, not the size of the page — a list of 40,000 contacts reports 40,000 whichever page you are on.
Retention and the clamped flag
List endpoints that support a date range (e.g. email logs) respect your plan's data-retention window. When your requested from date falls outside the retention window, it is silently moved forward to the earliest allowed date. The response includes a retention object that tells you when this happened:
"retention": {
"days": 7,
"clamped": true
}| Field | Meaning |
|---|---|
days | How many calendar days back your plan allows you to query |
clamped | true — your from date was earlier than the retention limit; results start from the retention boundary. false — your date range was within the allowed window |
Upgrade your plan to increase the retention window.
Rate limits
All authenticated endpoints are limited to 100 requests per 60-second window per API key. Every response includes three headers:
| Header | Value |
|---|---|
X-RateLimit-Limit | 100 |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
When the limit is exceeded, the API returns 429 with { "code": "rate_limit_exceeded", "error": "Rate limit exceeded. Please slow down." }. See Errors → 429 for the full shape and handling guidance.
Sending limits
In addition to the per-minute rate limit, your account has two independent sending caps that apply to POST /v1/emails/send and POST /v1/emails/batch:
| Limit | Status | Resets |
|---|---|---|
| Monthly — total emails per calendar month | 402 | 1st of each month (UTC) |
Daily — floor(monthlyLimit / 30) per day | 429 | Midnight UTC |
Unlimited plans (Enterprise) skip both checks. See Errors → 402 and Errors → 429 for the exact error shapes, including the batch variants.