Emails API

Send an email

Queue a transactional email for immediate or scheduled delivery. Supports raw HTML or saved templates, multiple recipients, plain-text fallback, and idempotent sends.

POST /v1/emails/send

Queue a single email for delivery. Returns an email ID you can use to check status or cancel a scheduled send.

πŸ”‘ API key β€” Authorization: Bearer qle_…
⚑ Idempotent πŸ“… Schedulable πŸ“¦ Up to 50 recipients

Request body

ParameterTypeRequiredDescription
fromstringrequiredVerified sender address. Either "email@domain.com" or "Name <email@domain.com>". The domain must be verified in your account.
tostringΒ | string[]requiredRecipient address(es). A single email string or an array of up to 50 addresses.
subjectstringrequiredEmail subject line. Max 998 characters. Required in HTML mode.
htmlstringrequiredHTML body of the email. Cannot be used together with template.
textstringoptionalPlain-text fallback. Auto-generated from the HTML when omitted.
replyTostringoptionalReply-to address. Does not need to be a verified domain.
scheduledAtstringoptionalISO 8601 datetime for deferred delivery. Must be in the future. Omit to send immediately.
metadataobjectoptionalArbitrary key-value data stored with the email record. Useful for linking sends to your internal IDs.
ParameterTypeRequiredDescription
fromstringrequiredVerified sender address. Either "email@domain.com" or "Name <email@domain.com>".
tostringΒ | string[]requiredRecipient address(es). A single email string or an array of up to 50 addresses.
templatestringrequiredSlug of a saved template (e.g. "welcome-email"). Cannot be used together with html.
variablesobjectoptionalHandlebars context passed to the template. Must include every {{variable}} referenced in the template body and subject β€” missing variables cause a 422.
subjectstringoptionalOverrides the template's subject when provided. The template's own subject is used when omitted.
replyTostringoptionalReply-to address.
scheduledAtstringoptionalISO 8601 datetime for deferred delivery. Must be in the future.
metadataobjectoptionalArbitrary key-value data stored with the email record.
Idempotency. Pass an Idempotency-Key: <unique-string> header to make the request safe to retry. Duplicate requests with the same key within 24 hours return the original response with an Idempotency-Replay: true header β€” no second email is sent.

Errors

StatusErrorDetails
400Sending domain not verifiedThe domain in your from address has not been verified. Body includes domain.
400scheduledAt must be a future datetimeThe supplied scheduledAt is in the past.
402Monthly limit reachedMonthly quota exhausted. Body includes limit, used, plan. Upgrade or wait for the next billing cycle.
404Template not foundNo template with the given slug exists in your account. Template mode only.
400Either html or template is requiredRequest body must include exactly one of html or template. Returned as a Zod validation error β€” see Errors β†’ 400 for the shape.
400subject is required when sending raw htmlsubject was omitted in HTML mode. Returned as a Zod validation error.
422Template render failed: …Handlebars strict mode β€” a {{variable}} in the template has no matching key in variables. Template mode only.
429Daily sending limit reachedDaily cap hit (β‰ˆ monthly limit Γ· 30). Body includes dailyLimit, dailySent, resetsAt ("midnight UTC"), plan.
503Sending temporarily paused β€” account under reviewAccount flagged for high bounce or complaint rate. Body includes reason: "bounce" or "complaint". Contact support.

Examples

curl -X POST https://api.quolle.com/v1/emails/send \
  -H "Authorization: Bearer qle_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.yourdomain.com>",
    "to": "customer@example.com",
    "subject": "Your order is confirmed",
    "html": "<h1>Order confirmed</h1><p>Thanks for your purchase!</p>"
  }'
const res = await fetch("https://api.quolle.com/v1/emails/send", {
  method: "POST",
  headers: {
    "Authorization": "Bearer qle_your_api_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Acme <hello@mail.yourdomain.com>",
    to: "customer@example.com",
    subject: "Your order is confirmed",
    html: "<h1>Order confirmed</h1><p>Thanks for your purchase!</p>",
  }),
});

if (!res.ok) {
  const err = await res.json();
  throw new Error(err.error);
}

const { id } = await res.json();
console.log("Queued:", id);
import requests

response = requests.post(
    "https://api.quolle.com/v1/emails/send",
    headers={
        "Authorization": "Bearer qle_your_api_key",
        "Content-Type": "application/json",
    },
    json={
        "from": "Acme <hello@mail.yourdomain.com>",
        "to": "customer@example.com",
        "subject": "Your order is confirmed",
        "html": "<h1>Order confirmed</h1><p>Thanks for your purchase!</p>",
    },
    timeout=15,
)
response.raise_for_status()
data = response.json()
print("Queued:", data["id"])
curl -X POST https://api.quolle.com/v1/emails/send \
  -H "Authorization: Bearer qle_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.yourdomain.com>",
    "to": "customer@example.com",
    "template": "order-confirmation",
    "variables": {
      "firstName": "Amara",
      "orderNumber": "ORD-8821",
      "totalNaira": "12,500"
    }
  }'
const res = await fetch("https://api.quolle.com/v1/emails/send", {
  method: "POST",
  headers: {
    "Authorization": "Bearer qle_your_api_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Acme <hello@mail.yourdomain.com>",
    to: "customer@example.com",
    template: "order-confirmation",
    variables: {
      firstName: "Amara",
      orderNumber: "ORD-8821",
      totalNaira: "12,500",
    },
  }),
});

const { id } = await res.json();
console.log("Queued:", id);
import requests

response = requests.post(
    "https://api.quolle.com/v1/emails/send",
    headers={
        "Authorization": "Bearer qle_your_api_key",
        "Content-Type": "application/json",
    },
    json={
        "from": "Acme <hello@mail.yourdomain.com>",
        "to": "customer@example.com",
        "template": "order-confirmation",
        "variables": {
            "firstName": "Amara",
            "orderNumber": "ORD-8821",
            "totalNaira": "12,500",
        },
    },
    timeout=15,
)
response.raise_for_status()
print("Queued:", response.json()["id"])
curl -X POST https://api.quolle.com/v1/emails/send \
  -H "Authorization: Bearer qle_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.yourdomain.com>",
    "to": "customer@example.com",
    "subject": "Your trial is ending soon",
    "html": "<p>Your 14-day trial ends tomorrow.</p>",
    "scheduledAt": "2026-07-10T09:00:00.000Z"
  }'
const res = await fetch("https://api.quolle.com/v1/emails/send", {
  method: "POST",
  headers: {
    "Authorization": "Bearer qle_your_api_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Acme <hello@mail.yourdomain.com>",
    to: "customer@example.com",
    subject: "Your trial is ending soon",
    html: "<p>Your 14-day trial ends tomorrow.</p>",
    scheduledAt: "2026-07-10T09:00:00.000Z",
  }),
});

const { id, status, scheduledAt } = await res.json();
console.log(`Scheduled ${id} for ${scheduledAt}`);
import requests

response = requests.post(
    "https://api.quolle.com/v1/emails/send",
    headers={
        "Authorization": "Bearer qle_your_api_key",
        "Content-Type": "application/json",
    },
    json={
        "from": "Acme <hello@mail.yourdomain.com>",
        "to": "customer@example.com",
        "subject": "Your trial is ending soon",
        "html": "<p>Your 14-day trial ends tomorrow.</p>",
        "scheduledAt": "2026-07-10T09:00:00.000Z",
    },
    timeout=15,
)
data = response.json()
print(f"Scheduled {data['id']} for {data['scheduledAt']}")

Response

200 OK Immediate send
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "message": "Email queued successfully"
}
200 OK Scheduled send
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "scheduled",
  "scheduledAt": "2026-07-10T09:00:00.000Z",
  "message": "Email scheduled successfully"
}

Error responses

402 β€” Monthly limit reached

{
  "error": "Monthly limit reached",
  "limit": 3000,
  "used": 3000,
  "plan": "Starter"
}

429 β€” Daily limit reached

{
  "error": "Daily sending limit reached",
  "dailyLimit": 100,
  "dailySent": 100,
  "resetsAt": "midnight UTC",
  "plan": "Starter"
}

503 β€” Account paused

{
  "error": "Sending temporarily paused β€” account under review",
  "reason": "bounce"
}
Checking delivery status. Retrieve an email by ID using GET /v1/emails/:id (API key required) 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.
Scheduling & cancelling. Pass a future scheduledAt (ISO 8601) on POST /v1/emails/send to schedule delivery β€” the response returns "status": "scheduled". The email won't leave the queue until its time arrives.
curl -X POST https://api.quolle.com/v1/emails/send \
  -H "Authorization: Bearer $QUOLLE_API_KEY" -H "Content-Type: application/json" \
  -d '{"from":"hello@mail.yourdomain.com","to":"you@example.com",
       "subject":"Your weekly digest","html":"<p>…</p>",
       "scheduledAt":"2026-12-25T09:00:00.000Z"}'

To cancel before it sends, call DELETE /v1/emails/:id/cancel (returns "Scheduled email cancelled"), or open the email in the dashboard β†’ Logs and click Cancel scheduled send. Once an email has left the queue it can no longer be cancelled.

Attachments. Add an attachments array to POST /v1/emails/send. Each item has a filename and either content (the file bytes as a base64 string) or path (a URL Quolle fetches at send time), plus an optional contentType. Limits: up to 20 files and 10Β MB total per email. Attachments aren't supported on /v1/emails/batch β€” send those individually.
curl -X POST https://api.quolle.com/v1/emails/send \
  -H "Authorization: Bearer $QUOLLE_API_KEY" -H "Content-Type: application/json" \
  -d '{"from":"Acme <hello@mail.yourdomain.com>","to":"you@example.com",
       "subject":"Your invoice","html":"<p>Invoice attached.</p>",
       "attachments":[
         {"filename":"invoice.pdf","content":"JVBERi0xLjQKJ…"},   // base64, or…
         {"filename":"terms.pdf","path":"https://files.acme.com/terms.pdf"}
       ]}'

Inline images. Give an attachment a contentId and reference it in your HTML with <img src="cid:the-id"> to embed it in the body instead of appending it.

Blocked types. Executable and script file types (.exe, .js, .bat, .vbs, and similar) are rejected β€” mail providers quarantine them anyway. Remote path URLs must be public http(s) (internal/private addresses are refused).

The SDKs make this easier β€” Python's quolle.attachment("invoice.pdf") and Go's quolle.FileAttachment("invoice.pdf") read and encode the file for you, and the CLI takes --attach ./invoice.pdf.

POST /v1/emails/batch

Send up to 100 emails in a single request. The entire batch is checked against your monthly and daily limits before any email is queued β€” if any limit would be exceeded, the whole batch is rejected.

πŸ”‘ API key β€” Authorization: Bearer qle_…
⚑ Idempotent πŸ“¦ Up to 100 emails per batch

Request body

ParameterTypeRequiredDescription
emailsobject[]requiredArray of 1–100 email objects. Each object accepts the same fields as POST /v1/emails/send.

Errors

StatusErrorDetails
402This batch of N would exceed your monthly limit…Body includes canSend β€” the number of emails you can still send this month.
429This batch would exceed your daily limit of N…Body includes canSend β€” the number you can still send today.
All-or-nothing. If the batch would exceed a limit, none of the emails are queued. Use the canSend value in the error response to split and retry a smaller batch.

Example

curl -X POST https://api.quolle.com/v1/emails/batch \
  -H "Authorization: Bearer qle_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      {
        "from": "hello@mail.yourdomain.com",
        "to": "alice@example.com",
        "subject": "Welcome, Alice!",
        "html": "<p>Thanks for joining.</p>"
      },
      {
        "from": "hello@mail.yourdomain.com",
        "to": "bob@example.com",
        "subject": "Welcome, Bob!",
        "html": "<p>Thanks for joining.</p>"
      }
    ]
  }'
const users = [
  { email: "alice@example.com", name: "Alice" },
  { email: "bob@example.com",   name: "Bob"   },
];

const res = await fetch("https://api.quolle.com/v1/emails/batch", {
  method: "POST",
  headers: {
    "Authorization": "Bearer qle_your_api_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    emails: users.map(u => ({
      from: "hello@mail.yourdomain.com",
      to: u.email,
      subject: `Welcome, ${u.name}!`,
      html: `<p>Hi ${u.name}, thanks for joining.</p>`,
    })),
  }),
});

const { queued, ids } = await res.json();
console.log(`Queued ${queued} emails`, ids);
import requests

users = [
    {"email": "alice@example.com", "name": "Alice"},
    {"email": "bob@example.com",   "name": "Bob"},
]

response = requests.post(
    "https://api.quolle.com/v1/emails/batch",
    headers={
        "Authorization": "Bearer qle_your_api_key",
        "Content-Type": "application/json",
    },
    json={
        "emails": [
            {
                "from": "hello@mail.yourdomain.com",
                "to": u["email"],
                "subject": f"Welcome, {u['name']}!",
                "html": f"<p>Hi {u['name']}, thanks for joining.</p>",
            }
            for u in users
        ]
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
print(f"Queued {data['queued']} emails:", data["ids"])

Response

200 OK
{
  "queued": 2,
  "ids": [
    "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "b2c3d4e5-f6a7-8901-bcde-f01234567891"
  ]
}

402 β€” Batch would exceed monthly limit

{
  "error": "This batch of 50 would exceed your monthly limit of 3000. You can send 12 more this month.",
  "canSend": 12
}

429 β€” Batch would exceed daily limit

{
  "error": "This batch would exceed your daily limit of 100. You can send 20 more today.",
  "canSend": 20
}

Test addresses

Simulate every delivery outcome β€” including bounces and spam complaints β€” without sending real email or affecting your sending reputation. Send to a reserved test address and Quolle replays the full lifecycle: the email appears in your logs, the status transitions happen, and your webhooks fire, but nothing ever leaves the platform.

AddressSimulatesWebhook events
delivered@test.quolle.comSuccessful deliveryemail.sent, email.delivered
bounced@test.quolle.comPermanent (hard) bounceemail.sent, email.bounced
complained@test.quolle.comSpam complaintemail.sent, email.complained
suppressed@test.quolle.comRecipient on your suppression listnone β€” suppressed sends emit no events
Why use these instead of a fake address? Deliberately mailing a dead address causes a real hard bounce, which counts against your bounce rate and can pause your sending. Test addresses are free of side effects: no suppression entries are created, deliverability stats are untouched, and simulated bounces never reach the mailbox provider.
Use test addresses on their own β€” don't mix them with real recipients in one send. Test sends count toward your monthly quota like normal emails.