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.
/v1/emails/sendQueue 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_β¦Request body
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | required | Verified sender address. Either "email@domain.com" or "Name <email@domain.com>". The domain must be verified in your account. |
| to | stringΒ | string[] | required | Recipient address(es). A single email string or an array of up to 50 addresses. |
| subject | string | required | Email subject line. Max 998 characters. Required in HTML mode. |
| html | string | required | HTML body of the email. Cannot be used together with template. |
| text | string | optional | Plain-text fallback. Auto-generated from the HTML when omitted. |
| replyTo | string | optional | Reply-to address. Does not need to be a verified domain. |
| scheduledAt | string | optional | ISO 8601 datetime for deferred delivery. Must be in the future. Omit to send immediately. |
| metadata | object | optional | Arbitrary key-value data stored with the email record. Useful for linking sends to your internal IDs. |
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | required | Verified sender address. Either "email@domain.com" or "Name <email@domain.com>". |
| to | stringΒ | string[] | required | Recipient address(es). A single email string or an array of up to 50 addresses. |
| template | string | required | Slug of a saved template (e.g. "welcome-email"). Cannot be used together with html. |
| variables | object | optional | Handlebars context passed to the template. Must include every {{variable}} referenced in the template body and subject β missing variables cause a 422. |
| subject | string | optional | Overrides the template's subject when provided. The template's own subject is used when omitted. |
| replyTo | string | optional | Reply-to address. |
| scheduledAt | string | optional | ISO 8601 datetime for deferred delivery. Must be in the future. |
| metadata | object | optional | Arbitrary key-value data stored with the email record. |
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
| Status | Error | Details |
|---|---|---|
| 400 | Sending domain not verified | The domain in your from address has not been verified. Body includes domain. |
| 400 | scheduledAt must be a future datetime | The supplied scheduledAt is in the past. |
| 402 | Monthly limit reached | Monthly quota exhausted. Body includes limit, used, plan. Upgrade or wait for the next billing cycle. |
| 404 | Template not found | No template with the given slug exists in your account. Template mode only. |
| 400 | Either html or template is required | Request body must include exactly one of html or template. Returned as a Zod validation error β see Errors β 400 for the shape. |
| 400 | subject is required when sending raw html | subject was omitted in HTML mode. Returned as a Zod validation error. |
| 422 | Template render failed: β¦ | Handlebars strict mode β a {{variable}} in the template has no matching key in variables. Template mode only. |
| 429 | Daily sending limit reached | Daily cap hit (β monthly limit Γ· 30). Body includes dailyLimit, dailySent, resetsAt ("midnight UTC"), plan. |
| 503 | Sending temporarily paused β account under review | Account 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
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "Email queued successfully"
}{
"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"
}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.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 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.
/v1/emails/batchSend 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_β¦Request body
| Parameter | Type | Required | Description |
|---|---|---|---|
| emails | object[] | required | Array of 1β100 email objects. Each object accepts the same fields as POST /v1/emails/send. |
Errors
| Status | Error | Details |
|---|---|---|
| 402 | This batch of N would exceed your monthly limitβ¦ | Body includes canSend β the number of emails you can still send this month. |
| 429 | This batch would exceed your daily limit of Nβ¦ | Body includes canSend β the number you can still send today. |
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
{
"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.
| Address | Simulates | Webhook events |
|---|---|---|
| delivered@test.quolle.com | Successful delivery | email.sent, email.delivered |
| bounced@test.quolle.com | Permanent (hard) bounce | email.sent, email.bounced |
| complained@test.quolle.com | Spam complaint | email.sent, email.complained |
| suppressed@test.quolle.com | Recipient on your suppression list | none β suppressed sends emit no events |