API Reference
Base URL: https://api.quolle.com · All requests and responses use JSON.
Authentication
All API requests require an API key in the Authorization header.
Authorization: Bearer qle_your_api_key
Create and manage API keys in the dashboard under API Keys. Each key is shown only once at creation — store it securely and never commit it to source control.
qle_. Store them as environment variables
(QUOLLE_API_KEY=qle_…) rather than hardcoding them in your codebase.
Emails
All email endpoints require an API key in the Authorization: Bearer header.
Idempotency keys
Add an Idempotency-Key header to any POST /send or
POST /batch request to make it safe to retry. If a request with the same key
(scoped to your account) has already succeeded, the cached response is returned immediately
with an Idempotency-Replay: true header. Keys expire after 24 hours.
curl -X POST https://api.quolle.com/v1/emails/send \
-H "Authorization: Bearer qle_your_api_key" \
-H "Idempotency-Key: order_invoice_12345" \
-H "Content-Type: application/json" \
-d '{ "from": "…", "to": "…", "subject": "…", "html": "…" }'
Send a single transactional email.
Idempotency-Key| Field | Type | Required | Description |
|---|---|---|---|
from | string | required | Verified sender address. Either "email@domain.com" or "Name <email@domain.com>". |
to | string | string[] | required | Recipient email address, or an array of up to 50 addresses |
subject | string | optional* | Email subject. Required when sending raw HTML. Optional in template mode — template provides its own subject; if given here it overrides the template's rendered subject. |
html | string | optional* | HTML body. Required if template is not given. Provide html or template — not both. |
template | string | optional* | Slug of a saved template to render (e.g. "welcome-email"). Required if html is not given. Provide template or html — not both. |
variables | object | optional | Handlebars context passed to the template. A {{variable}} reference with no matching key in this object causes a 422 at send time. Use {{#if var}} for optional values. |
text | string | optional | Plain-text fallback. Auto-generated from HTML if omitted. In template mode, the template's textBody takes priority when set. |
replyTo | string | optional | Reply-to address |
scheduledAt | string | optional | ISO 8601 datetime to delay delivery (e.g. 2026-12-25T09:00:00.000Z) |
type | string | optional | transactional (default) or marketing. Marketing adds RFC 8058 one-click unsubscribe headers and a footer link, and honours unsubscribes. Transactional carries neither — an OTP must never let someone opt out of their own login codes, and an unsubscribe never blocks it. Marketing requires a marketing plan. |
topicId | string | optional | UUID of a topic. Marketing only. When set, a recipient’s one-click unsubscribe removes them from that topic and leaves your other topics intact. Omit it and unsubscribing stops every marketing email you send to that person — there is nothing narrower to honour. |
metadata | object | optional | Arbitrary JSON metadata stored with the email |
// Response 200
{
"id": "a1b2c3d4-e5f6-...",
"message": "Email queued successfully"
}
// Response 200 — when scheduledAt is set
{
"id": "a1b2c3d4-e5f6-...",
"status": "scheduled",
"scheduledAt": "2026-12-25T09:00:00.000Z",
"message": "Email scheduled successfully"
}
Template mode — reference a saved template by its slug:
// Request body (template mode — no html field needed)
{
"from": "hello@mail.yourdomain.com",
"to": "jane@example.com",
"template": "welcome-email",
"variables": { "name": "Jane", "plan": "Starter" }
}
// subject is taken from the template's subject field.
// Add "subject": "..." to the request to override it.
template field
lets you send with any saved template directly from your API key — no JWT required.
Send up to 100 emails in a single request. The batch is all-or-nothing: every email is validated up front, the whole batch is checked against your monthly and daily limits before any email is queued, and on any failure nothing from the batch is sent.
Idempotency-Key| Field | Type | Required | Description |
|---|---|---|---|
emails | array | required | Array of email objects (same shape as /send, max 100) |
// Response 200
{
"queued": 3,
"ids": ["uuid-1", "uuid-2", "uuid-3"]
}
// Response 422 — one or more emails failed validation; nothing was queued.
// Every invalid item is reported with its zero-based index in emails[].
{
"error": "Batch validation failed — no emails were queued",
"errors": [
{ "index": 1, "error": "Sending domain not verified: unverified.com" },
{ "index": 4, "error": "scheduledAt must be a future datetime" }
]
}
Limit errors (402 monthly / 429 daily) include canSend — see Errors.
Send a transactional email triggered by a USSD callback. Accepts a phoneNumber field to look up the recipient across all contacts in your account; falls back to a direct to address if supplied instead. Returns a minimal { queued, ref } response optimised for USSD gateway handlers.
| Field | Type | Required | Description |
|---|---|---|---|
from | string | required | Verified sender address |
phoneNumber | string | optional* | Phone number to look up across all account contacts. Required if to is not given. |
to | string | optional* | Direct recipient address. Required if phoneNumber is not given. |
subject | string | required | Email subject |
html | string | required | HTML body |
text | string | optional | Plain-text fallback. Auto-generated from HTML if omitted. |
replyTo | string | optional | Reply-to address |
metadata | object | optional | Arbitrary JSON — use metadata.ussd for session context |
// Response 200
{
"queued": true,
"ref": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
// Response 404 — phone number not found in contacts
{
"error": "No contact found for this phone number"
}
See the USSD guide for full examples and gateway integration patterns.
Retrieve a single email by its UUID. Returns the full email record including current delivery status, open/click counts, and timestamps. Tenant-scoped — returns 404 if the ID doesn't belong to your account.
| Field | Type | Notes |
|---|---|---|
id | string | UUID of the email to retrieve (URL path parameter) |
// Response 200
{
"email": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"from": "hello@mail.yourdomain.com",
"to": "customer@example.com",
"subject": "Welcome!",
"status": "delivered",
"opensCount": 2,
"clicksCount": 1,
"openedAt": "2026-07-05T10:15:00.000Z",
"clickedAt": "2026-07-05T10:16:00.000Z",
"sentAt": "2026-07-05T10:00:00.000Z",
"createdAt": "2026-07-05T09:58:00.000Z",
"messageId": "0100019a2b3c4d5e-f6a7b8c9-...",
"provider": "primary"
}
}
// Response 404
{ "error": "Email not found" }
Status values: "queued" · "scheduled" · "sending" · "sent" · "delivered" · "bounced" · "failed" · "cancelled" · "suppressed" (all recipients on your suppression list — nothing was sent)
Provider values: "primary", or "failover" when the email was delivered through Quolle's failover infrastructure during an incident on the primary path.
Node.js SDK: const email = await quolle.emails.get(id)
Cancel a scheduled email before it is dispatched. Only emails with status "scheduled" can be cancelled — immediate sends are queued for dispatch right away and cannot be recalled. Tenant-scoped — returns 404 if the ID doesn't belong to your account.
// Response 200
{
"message": "Scheduled email cancelled"
}
// Response 400 — email exists but is not in "scheduled" status
{ "error": "Email is not scheduled" }
// Response 404
{ "error": "Email not found" }
Node.js SDK: const { message } = await quolle.emails.cancel(id)
GET /v1/emails/:id (above) to check its current status programmatically.
For real-time notifications, use webhooks — subscribe to
email.delivered, email.bounced, and other events. View
delivery history in the
dashboard
under Logs.
Webhooks
Configure webhooks from the dashboard to receive real-time HTTP notifications when emails are sent, delivered, bounced, or marked as spam. See the Webhooks guide for full documentation: event types, payload format, signature verification, and handler examples.
Billing
List all available plans with features and pricing in Naira. Public — no auth required.
// Response 200
{
"plans": [
{
"id": "uuid",
"name": "Starter",
"monthlyLimit": 3000,
"priceNaira": 0,
"paystackPlanCode": null,
"retentionDays": 7,
"features": ["3,000 emails/mo", "API access", "SMTP access", "1 domain", "Email templates", "Analytics dashboard"]
},
{
"id": "uuid",
"name": "Growth",
"monthlyLimit": 50000,
"priceNaira": 20000,
"paystackPlanCode": "PLN_abc123",
"retentionDays": 90,
"features": ["50,000 emails/mo", "API access", "SMTP access", "5 domains", "Email templates", "Analytics dashboard", "Webhook support"]
}
]
}
Campaigns
Campaigns (broadcasts) send one message to a whole contact list. They require a marketing subscription, which is billed separately from your email plan and priced by how many contacts you hold rather than how many emails you send.
Campaigns run on their own sending reputation. If one draws complaints, campaign sending is paused and your transactional email — OTPs, receipts, password resets — keeps flowing untouched.
How many contacts would actually receive this, before you send. Always call this first — the reachable count is smaller than the list size once unsubscribes, bounces and topic opt-outs are removed.
// Request
{ "listId": "uuid", "topicId": "uuid" } // topicId optional
// Response 200
{
"recipientCount": 940,
"sample": ["ada@example.com", "grace@example.com"],
"note": "Unsubscribed, bounced and complained addresses are excluded, along with anyone who opted out of this topic."
}
Send one copy to yourself to check how it renders.
The recipient must be an address your account controls: your login email, an active team member, or a mailbox on one of your verified domains. Limited to 20 per hour. Both limits exist because this is the one marketing path that accepts a free-form recipient and skips the transactional quota — without them it would be unmetered mail to anyone.
// Request — same body as a campaign, plus `to`
{ "listId": "uuid", "to": "you@yourdomain.com", "from": "hello@mail.yourdomain.com",
"subject": "August newsletter", "html": "<h1>Hello</h1>" }
// Response 200
{ "id": "uuid", "message": "Test sent to you@yourdomain.com" }
// Response 403
{ "error": "Test sends can only go to your own account email, an active team member, or an address on one of your verified domains." }
Create a campaign. With scheduledAt it waits; without, it dispatches immediately.
| Field | Type | Notes | |
|---|---|---|---|
listId | string | required | UUID of the contact list. |
from | string | required | Must be on a verified sending domain. |
subject | string | required | Up to 200 characters. |
html | string | required | Supports {{firstName}}, {{lastName}} and {{email}} merge tags, substituted per recipient. |
topicId | string | optional | Scopes unsubscribes to a topic, and excludes contacts who opted out of it. |
name | string | optional | Internal label. Never shown to recipients. |
replyTo | string | optional | Reply-to address. |
scheduledAt | string | optional | ISO 8601, must be in the future. The audience is re-resolved at dispatch, so anyone who unsubscribes in the meantime is never mailed. |
// Response 200 — immediate
{ "id": "uuid", "status": "sending", "recipientCount": 940 }
// Response 200 — scheduled
{ "id": "uuid", "status": "scheduled", "scheduledAt": "2026-09-01T09:00:00.000Z", "estimatedRecipients": 940 }
// Response 402 — no marketing subscription
{ "error": "A marketing plan is required to send campaigns", "upgrade": "marketing" }
// Response 400 — nobody reachable
{ "error": "Nobody on that list is subscribed to this topic" }
List your campaigns with engagement figures.
Open and click rates are measured against delivered mail, not everything attempted, so bounced addresses cannot flatter a rate.
One campaign with per-recipient results (first 1,000).
// Response 200
{
"id": "uuid",
"subject": "August newsletter",
"status": "sent",
"sentAt": "2026-08-15T10:00:00.000Z",
"stats": { "recipients": 940, "delivered": 928, "bounced": 12, "opened": 402, "clicked": 88,
"openRate": 43.3, "clickRate": 9.5 },
"recipients": [
{ "email": "ada@example.com", "status": "delivered", "openedAt": "2026-08-15T10:12:00.000Z",
"clickedAt": null, "opensCount": 2, "clicksCount": 0 }
]
}
Cancel a draft or scheduled campaign. One already sending cannot be recalled — the mail is with the provider.
Your marketing subscription and current usage.
// Response 200
{
"plan": { "id": "uuid", "name": "Marketing 10k", "priceNaira": 25000, "maxMarketingContacts": 10000 },
"subscribed": true,
"contacts": 4210,
"contactLimit": 10000,
"monthlySendLimit": null, // null = unmetered (all paid tiers)
"sentThisMonth": 0,
"unlimitedSends": true,
"periodEnd": "2026-09-14T00:00:00.000Z",
"paused": false,
"pausedReason": null
}
The free marketing tier is the one exception to unmetered sending: it carries a monthly campaign-email
cap, reported here as monthlySendLimit.
Contacts
Campaigns can only be sent to saved contacts. That restriction is deliberate: it is what
makes a plan priced by audience size honest, and it stops type: "marketing"
becoming unmetered mail to arbitrary addresses.
List your contact lists with a contact count for each.
// Response 200
{
"lists": [
{ "id": "uuid", "name": "Newsletter subscribers", "contactCount": 1240, "createdAt": "2026-08-01T09:00:00.000Z" }
]
}
Create a contact list.
// Request
{ "name": "Newsletter subscribers" }
// Response 201
{ "id": "uuid", "name": "Newsletter subscribers" }
Delete a list and every contact on it. Not reversible.
Suppression records survive this. Anyone who unsubscribed stays suppressed even after their contact row is gone — deleting a list is not a way to reset consent.
Bulk-import contacts from a CSV file. Send as
multipart/form-data with the file in a file field.
Recognised columns: email (required), firstName,
lastName, phone. Existing addresses on the list are skipped rather than
duplicated, and previously unsubscribed addresses stay unsubscribed — re-importing someone
is not consent, and treating it as such is how senders end up on blocklists.
curl -X POST https://api.quolle.com/v1/contacts/LIST_ID/import \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@contacts.csv"
// Response 200
{ "imported": 812, "skipped": 14, "invalid": 3 }
// Response 403 — billing state blocks imports before the file is even parsed
{ "error": "Your subscription is past due — resolve billing to import contacts." }
Add a contact to a list. firstName and lastName are available as merge tags in campaigns.
// Request
{
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Okafor",
"metadata": { "plan": "pro" }
}
// Response 201
{ "id": "uuid", "email": "ada@example.com" }
List contacts on a list, including their unsubscribe state.
Remove a contact from a list.
Note: deleting a contact does not delete their suppression record. Someone who unsubscribed stays suppressed even if you re-import them — re-importing an address is not consent.
Topics
Topics are the categories your recipients choose between — “Product updates”, “Newsletter”, “Offers”. Without them, a reader tired of one kind of email has only one option: stop everything. The names and descriptions you set here are shown publicly in the hosted preference centre, so write them for recipients rather than for yourself.
A contact is subscribed to every topic by default. Only a deliberate opt-out is recorded, which means creating a new topic never requires a backfill and never silently excludes anyone. An account-wide unsubscribe sits above topics: someone who opted out entirely stays out of every topic, including ones you create later.
List your topics. Pass ?includeArchived=true to include archived ones.
// Response 200
{
"topics": [
{
"id": "uuid",
"name": "Product updates",
"description": "New features and improvements, roughly monthly",
"position": 0,
"archived": false,
"optedOut": 37,
"createdAt": "2026-08-15T09:00:00.000Z"
}
]
}
optedOut is how many contacts have opted out of this topic specifically. A number
climbing faster than your others is telling you something about that topic, not about your list.
Create a topic. Names must be unique within your account.
| Field | Type | Notes | |
|---|---|---|---|
name | string | required | 1–60 characters. Shown to recipients. |
description | string | optional | Up to 200 characters. Stating how often you send measurably reduces opt-outs — people leave lists that surprise them. |
position | integer | optional | Sort order in the preference centre. Default 0. |
// Request
{ "name": "Product updates", "description": "New features and improvements, roughly monthly" }
// Response 201
{ "id": "uuid", "name": "Product updates", "message": "Topic \"Product updates\" created" }
// Response 409
{ "error": "You already have a topic with that name" }
Update a topic’s name, description, position, or archived state. All fields optional.
Archives the topic — it stops being sendable and disappears from the preference centre.
Deliberately not a hard delete. Opt-out records outlive the topic, so restoring it later cannot silently re-subscribe the people who asked to leave it.
// Response 200
{ "message": "\"Offers\" archived. Existing opt-outs are kept, so restoring it won't re-subscribe anyone." }
The contacts who opted out of this topic, most recent first. Up to 500.
// Response 200
{
"topic": { "id": "uuid", "name": "Product updates" },
"optOuts": [
{ "email": "ada@example.com", "name": "Ada Okafor", "optedOutAt": "2026-08-14T18:22:00.000Z" }
]
}
Preference centre
Every marketing email carries a link to a hosted preference centre where recipients tick the topics they want. It is token-authenticated, so it works from an email with no login, and the token is signed over both your account and the recipient’s address — a link issued by one sender can never be replayed against another’s contact of the same name. You do not need to host or build anything.
Re-subscribing to any topic there also lifts a previous account-wide unsubscribe. A preference centre that can only take things away is not one.
Rate limits
All authenticated endpoints are rate-limited to 100 requests per minute per API key.
Exceeding this returns a 429 Too Many Requests response.
| Header | Description |
|---|---|
X-RateLimit-Limit | 100 — maximum requests per window |
X-RateLimit-Remaining | Requests remaining in the current 60-second window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
Error codes
| Status | Meaning |
|---|---|
400 | Bad Request — invalid or missing fields |
401 | Unauthorized — missing or invalid API key |
402 | Payment Required — monthly email limit reached for your plan |
404 | Not Found — resource does not exist (e.g. template slug not found) |
422 | Unprocessable — validation error (missing field, template render failure) |
429 | Too Many Requests — rate limit or daily sending cap exceeded |
503 | Service Unavailable — account sending paused (see below) |
500 | Server Error — something went wrong on our side |
Sending limits
Quolle enforces two independent send caps per account: a monthly rolling total and a daily
soft cap (floor(monthlyLimit / 30)). Both apply to
POST /v1/emails/send and POST /v1/emails/batch. Unlimited plans
(Enterprise) skip both checks.
Monthly limit — 402
Returned when the account's monthly email allowance is exhausted:
// HTTP 402 — monthly limit reached
{
"error": "Monthly limit reached",
"limit": 3000,
"used": 3000,
"plan": "Starter"
}
Resets on the 1st of each calendar month (UTC). Upgrade your plan to increase the limit.
Daily limit — 429
Returned when you exceed the daily cap (floor(monthlyLimit / 30)). For Starter that is 100/day; for Growth it is 1,666/day:
// HTTP 429 — daily limit reached
{
"error": "Daily sending limit reached",
"dailyLimit": 100,
"dailySent": 100,
"resetsAt": "midnight UTC",
"plan": "Starter"
}
Batch limit shape
POST /v1/emails/batch checks both limits up front before queuing any email.
When the whole batch would exceed a cap, the response includes canSend — how
many you can still send in the current window:
// HTTP 402 — batch would exceed monthly limit
{ "error": "This batch of 50 would exceed your monthly limit of 3000. You can send 0 more this month.", "canSend": 0 }
// HTTP 429 — batch would exceed daily limit
{ "error": "This batch would exceed your daily limit of 100. You can send 20 more today.", "canSend": 20 }
Account paused — 503
Quolle monitors bounce and complaint rates via CloudWatch. If either rate crosses a
threshold, sending is automatically paused for the entire account to protect your
domain reputation. Any call to POST /v1/emails/send or
POST /v1/emails/batch while paused returns:
// HTTP 503 — account sending paused
{
"error": "Sending temporarily paused — account under review",
"reason": "bounce"
}
// reason: "bounce" | "complaint"
Roadmap
The following features are managed from the dashboard today. Programmatic API access is on the roadmap:
-
Template management API — create, update, and version email templates
via the API. Note: you can already send with saved templates today using the
templateandvariablesfields onPOST /v1/emails/send. - Stats & logs API — query delivery analytics, daily timeseries, and email logs programmatically. Currently available in the dashboard.