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.

Keys start with 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": "…" }'
POST /v1/emails/send

Send a single transactional email.

🔑 Requires API key · Supports Idempotency-Key
FieldTypeRequiredDescription
fromstringrequiredVerified sender address. Either "email@domain.com" or "Name <email@domain.com>".
tostring | string[]requiredRecipient email address, or an array of up to 50 addresses
subjectstringoptional*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.
htmlstringoptional*HTML body. Required if template is not given. Provide html or template — not both.
templatestringoptional*Slug of a saved template to render (e.g. "welcome-email"). Required if html is not given. Provide template or html — not both.
variablesobjectoptionalHandlebars 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.
textstringoptionalPlain-text fallback. Auto-generated from HTML if omitted. In template mode, the template's textBody takes priority when set.
replyTostringoptionalReply-to address
scheduledAtstringoptionalISO 8601 datetime to delay delivery (e.g. 2026-12-25T09:00:00.000Z)
typestringoptionaltransactional (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.
topicIdstringoptionalUUID 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.
metadataobjectoptionalArbitrary 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.
Templates are created and managed in the dashboard. The template field lets you send with any saved template directly from your API key — no JWT required.
POST /v1/emails/batch

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.

🔑 Requires API key · Supports Idempotency-Key
FieldTypeRequiredDescription
emailsarrayrequiredArray 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.

POST /v1/emails/ussd

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.

🔑 Requires API key
FieldTypeRequiredDescription
fromstringrequiredVerified sender address
phoneNumberstringoptional*Phone number to look up across all account contacts. Required if to is not given.
tostringoptional*Direct recipient address. Required if phoneNumber is not given.
subjectstringrequiredEmail subject
htmlstringrequiredHTML body
textstringoptionalPlain-text fallback. Auto-generated from HTML if omitted.
replyTostringoptionalReply-to address
metadataobjectoptionalArbitrary 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.

GET /v1/emails/:id

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.

🔑 Requires API key
FieldTypeNotes
idstringUUID 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)

DELETE /v1/emails/:id/cancel

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.

🔑 Requires API key
// 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)

Checking delivery status. Retrieve an email by ID using 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.

Webhooks are available on Growth and above. Each webhook includes a secret returned once at creation — use it to verify that requests came from Quolle. See Verifying signatures.

Billing

GET /v1/billing/plans

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.

POST /v1/broadcasts/preview

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.

🔑 Requires API key
// 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."
}
POST /v1/broadcasts/test

Send one copy to yourself to check how it renders.

🔑 Requires API key

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." }
POST /v1/broadcasts

Create a campaign. With scheduledAt it waits; without, it dispatches immediately.

🔑 Requires API key
FieldTypeNotes
listIdstringrequiredUUID of the contact list.
fromstringrequiredMust be on a verified sending domain.
subjectstringrequiredUp to 200 characters.
htmlstringrequiredSupports {{firstName}}, {{lastName}} and {{email}} merge tags, substituted per recipient.
topicIdstringoptionalScopes unsubscribes to a topic, and excludes contacts who opted out of it.
namestringoptionalInternal label. Never shown to recipients.
replyTostringoptionalReply-to address.
scheduledAtstringoptionalISO 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" }
GET /v1/broadcasts

List your campaigns with engagement figures.

🔑 Requires API key

Open and click rates are measured against delivered mail, not everything attempted, so bounced addresses cannot flatter a rate.

GET /v1/broadcasts/:id

One campaign with per-recipient results (first 1,000).

🔑 Requires API key
// 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 }
  ]
}
POST /v1/broadcasts/:id/cancel

Cancel a draft or scheduled campaign. One already sending cannot be recalled — the mail is with the provider.

🔑 Requires API key
GET /v1/broadcasts/plan/usage

Your marketing subscription and current usage.

🔑 Requires API key
// 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.

GET /v1/contacts/lists

List your contact lists with a contact count for each.

🔑 Requires API key
// Response 200
{
  "lists": [
    { "id": "uuid", "name": "Newsletter subscribers", "contactCount": 1240, "createdAt": "2026-08-01T09:00:00.000Z" }
  ]
}
POST /v1/contacts/lists

Create a contact list.

🔑 Requires API key
// Request
{ "name": "Newsletter subscribers" }

// Response 201
{ "id": "uuid", "name": "Newsletter subscribers" }
DELETE /v1/contacts/lists/:id

Delete a list and every contact on it. Not reversible.

🔑 Requires API key

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.

POST /v1/contacts/:listId/import

Bulk-import contacts from a CSV file. Send as multipart/form-data with the file in a file field.

🔑 Requires API key

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." }
POST /v1/contacts/:listId

Add a contact to a list. firstName and lastName are available as merge tags in campaigns.

🔑 Requires API key
// Request
{
  "email": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Okafor",
  "metadata": { "plan": "pro" }
}

// Response 201
{ "id": "uuid", "email": "ada@example.com" }
GET /v1/contacts/:listId

List contacts on a list, including their unsubscribe state.

🔑 Requires API key
DELETE /v1/contacts/:listId/:contactId

Remove a contact from a list.

🔑 Requires API key

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.

GET /v1/topics

List your topics. Pass ?includeArchived=true to include archived ones.

🔑 Requires API key
// 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.

POST /v1/topics

Create a topic. Names must be unique within your account.

🔑 Requires API key
FieldTypeNotes
namestringrequired1–60 characters. Shown to recipients.
descriptionstringoptionalUp to 200 characters. Stating how often you send measurably reduces opt-outs — people leave lists that surprise them.
positionintegeroptionalSort 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" }
PATCH /v1/topics/:id

Update a topic’s name, description, position, or archived state. All fields optional.

🔑 Requires API key
DELETE /v1/topics/:id

Archives the topic — it stops being sendable and disappears from the preference centre.

🔑 Requires API key

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." }
GET /v1/topics/:id/opt-outs

The contacts who opted out of this topic, most recent first. Up to 500.

🔑 Requires API key
// 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.

HeaderDescription
X-RateLimit-Limit100 — maximum requests per window
X-RateLimit-RemainingRequests remaining in the current 60-second window
X-RateLimit-ResetUnix timestamp (seconds) when the window resets

Error codes

StatusMeaning
400Bad Request — invalid or missing fields
401Unauthorized — missing or invalid API key
402Payment Required — monthly email limit reached for your plan
404Not Found — resource does not exist (e.g. template slug not found)
422Unprocessable — validation error (missing field, template render failure)
429Too Many Requests — rate limit or daily sending cap exceeded
503Service Unavailable — account sending paused (see below)
500Server 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"
This is not auto-cleared. The pause is intentionally held until an operator confirms the issue is resolved — a rate recovery caused by sending stopping is not the same as the underlying list problem being fixed. If you receive a 503, contact support. Do not retry automatically; the pause will not lift on its own.

Roadmap

The following features are managed from the dashboard today. Programmatic API access is on the roadmap: