Send Email API: Send, Batch, Schedule and Track Emails
By Lalit Kumar Jangid15 min
On this page+
A send email API lets your server send an email with one HTTPS request instead of an SMTP connection. With Cresca you POST JSON to https://api.cresca.xyz/api/v1/emails with an API key, get back an email id within milliseconds, and the message goes out from your verified domain within seconds. The same Emails API also sends batches of up to 100, schedules sends up to 30 days ahead, and lets you reschedule, cancel and look up every email.
This is a deep dive on the Cresca email sending API, the Emails API, only. For the big picture, start with the transactional email API overview.
In this guide
- Authentication and keys
- POST /emails: every field, with cURL, Node.js and Python
- POST /emails/batch: up to 100 emails per request
- GET /emails/:id and GET /emails with pagination
- PATCH /emails/:id (reschedule) and POST /emails/:id/cancel
- Statuses, Idempotency-Key, errors and rate limits
- Recipes: password reset, order receipt, scheduled reminder, safe retries
Before you send: domain, key and headers
Every from address must be an active sender on a domain you verified in Cresca. Set that up first under sending domains; the DNS side is covered in SPF, DKIM and DMARC done right.
Create a key on the API keys page of the app. Keys start with cr_live_ and are shown once. Send the key as a bearer token (the X-API-Key header also works). Call the API from your server only, never from browser code.
Authorization: Bearer cr_live_xxxxxxxxx Content-Type: application/json
| Key permission | Can call |
|---|---|
| Sending access | POST /emails and POST /emails/batch. Can be limited to one verified domain. |
| Full access | Every endpoint: send, retrieve, list, reschedule, cancel, plus templates and contacts. |
Use a sending-access key in production and a full-access key for scripts that read or manage email. A sending-access key that calls any other endpoint gets 401 restricted_api_key. Details: Authentication.
Send an email: POST /emails
POST https://api.cresca.xyz/api/v1/emails accepts one email for up to 50 recipients across to, cc and bcc. The response arrives as soon as the email is stored and queued. Reference: Send email.
| Field | Type | Notes |
|---|---|---|
from | string | Required unless the template has a default sender. hello@acme.com or Acme <hello@acme.com>. |
to | string | string[] | Required. Up to 50 recipients in total with cc and bcc. |
subject | string | Required unless the template has a default subject. One line, up to 998 characters. |
html | string | HTML body. Provide html, text or template. |
text | string | Plain-text body. Generated from html if omitted; send an empty string to leave it out. |
cc, bcc | string | string[] | Copy and blind-copy recipients. |
reply_to | string | string[] | Defaults to the sender's Reply-To, if set. |
template | object | { id, variables }: a published template id or alias. Cannot be combined with html or text. |
scheduled_at | string | ISO 8601 time up to 30 days ahead. A past time sends now. |
tags | object[] | Up to 10 { name, value } pairs: ASCII letters, numbers, _ and -. |
headers | object | Up to 50 custom headers. Headers Cresca controls (From, To, Subject, Message-ID...) are rejected. |
attachments | object[] | Up to 20 files, 7 MB total: filename, base64 content, optional content_type, content_id. |
Plus one optional header: Idempotency-Key, covered below. Unknown body fields are ignored rather than rejected, and the request body can be up to 10 MB.
cURL
curl -X POST https://api.cresca.xyz/api/v1/emails \ -H "Authorization: Bearer cr_live_xxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: welcome/user-123" \ -d '{ "from": "Acme <hello@acme.com>", "to": ["ana@example.com"], "reply_to": "support@acme.com", "subject": "Welcome to Acme", "html": "<p>Hi Ana, your account is ready.</p>", "tags": [{ "name": "category", "value": "welcome" }] }'
Node.js (fetch, Node 18+)
const res = await fetch('https://api.cresca.xyz/api/v1/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'welcome/user-123', }, body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: ['ana@example.com'], subject: 'Welcome to Acme', html: '<p>Hi Ana, your account is ready.</p>', tags: [{ name: 'category', value: 'welcome' }], }), }); const body = await res.json(); if (!res.ok) throw new Error(`${body.name}: ${body.message}`); console.log(body.id);
Python (requests)
import os, requestsres = requests.post( ",[object Object],", headers={ "Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}", "Idempotency-Key": "welcome/user-123", }, json={ "from": "Acme <,[object Object],>", "to": [",[object Object],"], "subject": "Welcome to Acme", "html": "<p>Hi Ana, your account is ready.</p>", }, timeout=10, ) body = res.json() if not res.ok: raise RuntimeError(f"{body['name']}: {body['message']}") print(body["id"])
Response, 200 OK. The id is a UUID; store it next to the record that triggered the email.
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
Attachments
Attachments are sent inline as base64. Attaching by path (a URL) is not supported yet and returns 422 invalid_attachment, as do executable types such as .exe and .js. Set content_id to show an inline image with <img src="cid:logo">.
"attachments": [ { "filename": "invoice.pdf", "content": "JVBERi0xLjQKJ...", "content_type": "application/pdf" } ]
Common errors for this endpoint: 403 invalid_from_address (sender not active on a verified domain, or the key is limited to another domain), 422 missing_required_field (no to, from or subject), 422 validation_error (bad address, more than 50 recipients, template combined with html, unpublished template or missing template variable), 404 not_found (unknown template) and 429 monthly_quota_exceeded.
Batch email API: POST /emails/batch
POST /emails/batch takes a JSON array of up to 100 emails, each with the same fields as a single send except attachments and scheduled_at. It is all or nothing: if one email is invalid, nothing is sent and the message names the failing email by zero-based index, such as Email 3: Invalid `to` field.... One Idempotency-Key covers the whole batch. Reference: Send batch emails.
curl -X POST https://api.cresca.xyz/api/v1/emails/batch \ -H "Authorization: Bearer cr_live_xxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: weekly-summary/2026-10-19" \ -d '[ { "from": "Acme <hello@acme.com>", "to": "ana@example.com", "subject": "Your weekly summary", "html": "<p>Hi Ana, here is your summary.</p>" }, { "from": "Acme <hello@acme.com>", "to": "ben@example.com", "subject": "Your weekly summary", "html": "<p>Hi Ben, here is your summary.</p>" } ]'
const users = [{ name: 'Ana', email: 'ana@example.com' }, { name: 'Ben', email: 'ben@example.com' }];const res = await fetch(',[object Object],', { method: 'POST', headers: { Authorization: ,[object Object],, 'Content-Type': 'application/json', 'Idempotency-Key': 'weekly-summary/2026-10-19', }, body: JSON.stringify(users.map((u) => ({ from: 'Acme <,[object Object],>', to: u.email, subject: 'Your weekly summary', html: ,[object Object],, }))), }); const { data } = await res.json();
users = [{"name": "Ana", "email": "ana@example.com"}, {"name": "Ben", "email": "ben@example.com"}] emails = [ {"from": "Acme <hello@acme.com>", "to": u["email"], "subject": "Your weekly summary", "html": f"<p>Hi {u['name']}, here is your summary.</p>"} for u in users ] res = requests.post( "https://api.cresca.xyz/api/v1/emails/batch", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}", "Idempotency-Key": "weekly-summary/2026-10-19"}, json=emails, timeout=30, ) ids = [item["id"] for item in res.json()["data"]]
Ids come back in the same order as the request:
{ "data": [ { "id": "b6d1f2a0-5e2f-4c8e-9a51-0c8b5f1d2e33" }, { "id": "0f7e3c11-9b2a-4f6d-8e44-7a1c2d3e4f55" } ] }
Batch-specific errors are 422 validation_error for a body that is not an array, more than 100 items, or an item with attachments or scheduled_at. Escape user input you put into html yourself, or use a template, where variables are HTML-escaped for you.
Retrieve an email: GET /emails/:id
Returns one email with its body and latest event. Needs a full-access key. Emails are kept for 30 days; an id from another account returns 404 not_found. Reference: Retrieve email.
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \ -H "Authorization: Bearer cr_live_xxxxxxxxx"
res = requests.get(f"https://api.cresca.xyz/api/v1/emails/{email_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10) print(res.json()["last_event"])
{ "object": "email", "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "from": "Acme <hello@acme.com>", "to": ["user@example.com"], "cc": null, "bcc": null, "reply_to": ["support@acme.com"], "subject": "Reset your password", "html": "<p>Click <a href=\"https://acme.com/reset?token=abc\">here</a> to reset your password.</p>", "text": "Click here (https://acme.com/reset?token=abc) to reset your password.", "created_at": "2026-10-19T09:14:03.120Z", "scheduled_at": null, "last_event": "delivered", "tags": [{ "name": "category", "value": "password_reset" }] }
List emails with pagination: GET /emails
Lists your API emails, newest first, without html and text. Query parameters: limit (1 to 100, default 20), after (an email id; returns older emails) or before (newer emails). Use one cursor, not both. Reference: List emails.
curl "https://api.cresca.xyz/api/v1/emails?limit=2" \ -H "Authorization: Bearer cr_live_xxxxxxxxx"
{ "object": "list", "has_more": true, "data": [ { "object": "email", "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "from": "Acme <hello@acme.com>", "to": ["user@example.com"], "cc": null, "bcc": null, "reply_to": null, "subject": "Reset your password", "created_at": "2026-10-19T09:14:03.120Z", "scheduled_at": null, "last_event": "delivered", "tags": [] } ] }
Walk every page by passing the last id as after while has_more is true:
let after = null; do { const url = new URL('https://api.cresca.xyz/api/v1/emails'); url.searchParams.set('limit', '100'); if (after) url.searchParams.set('after', after); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}` } }); const page = await res.json(); for (const email of page.data) console.log(email.id, email.last_event); after = page.has_more ? page.data.at(-1).id : null; } while (after);
An id that is not one of your emails gives 422 validation_error ("Invalid pagination cursor").
Schedule email API: PATCH /emails/:id
To schedule, send scheduled_at with the email. To move it later, PATCH /emails/:id with a new scheduled_at (required, up to 30 days ahead; a past time sends now). Only emails whose status is scheduled can be updated; anything else returns 422 validation_error. Reference: Update email.
curl -X PATCH https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \ -H "Authorization: Bearer cr_live_xxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-10-21T09:00:00Z" }'
{ "object": "email", "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
Cancel an email: POST /emails/:id/cancel
Stops an email while it is scheduled or queued. Once sending has started it returns 422 validation_error. A canceled email does not count toward your allowance. Reference: Cancel email.
curl -X POST https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel \ -H "Authorization: Bearer cr_live_xxxxxxxxx"
{ "object": "email", "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
Statuses and the event timeline
The API returns the latest event as last_event. The dashboard shows the full per-recipient timeline. See Email lifecycle.
| last_event | Meaning |
|---|---|
queued | Accepted, waiting to send. |
scheduled | Waiting for scheduled_at. Can be rescheduled or canceled. |
sending | Being handed to the mail infrastructure. |
sent | Handed over for delivery. |
delivered | The recipient's mail server accepted it. |
delivery_delayed | Delivery is being retried. |
bounced | The recipient's server rejected it. |
complained | The recipient marked it as spam. |
failed | Could not be sent; the dashboard shows why. |
suppressed | Not sent: every recipient is suppressed. |
canceled | Canceled before sending. |
An email with several recipients has one status that only moves forward, while last_event is the most recent event. Rarely, last_event is rejected, when the mail infrastructure refuses the message; the email then counts as failed. delivered means the receiving server accepted it, not that it landed in the inbox. Suppressed recipients (hard bounces, complaints, manual entries) are skipped and the request still returns 200; see Suppression. Marketing unsubscribes do not block transactional email, one of the differences explained in transactional vs marketing email.
Idempotency-Key: retry without duplicates
Send an Idempotency-Key header (1 to 256 characters) on POST /emails or POST /emails/batch. Keys are scoped to your account and last 24 hours. Reference: Idempotency.
| Situation | Result |
|---|---|
| Same key, same body, within 24 hours | The original response with header idempotent-replayed: true. Nothing is re-sent. |
| Same key, different body | 409 invalid_idempotent_request |
| Same key while the first request runs | 409 concurrent_idempotent_requests. Retry shortly. |
| First request failed | The key is released; the retry runs normally. |
| Key shorter than 1 or longer than 256 characters | 400 invalid_idempotency_key |
Derive keys from the event, not the clock: order-confirmation/order-1042, password-reset/reset-request-9f2c.
Errors when sending
Every error has the same shape. Branch on name; messages may change. Full list: Errors.
{ "statusCode": 422, "name": "validation_error", "message": "Invalid `to` field. The email address needs to follow the `email@example.com` or `Name <email@example.com>` format." }
| Status | name | Retry? |
|---|---|---|
| 401 | missing_api_key, restricted_api_key | No |
| 403 | invalid_api_key, invalid_from_address | No |
| 404 | not_found | No |
| 409 | invalid_idempotent_request | No |
| 409 | concurrent_idempotent_requests | Yes, shortly |
| 413 | validation_error (body over 10 MB) | No |
| 422 | missing_required_field, validation_error, invalid_attachment | No |
| 429 | rate_limit_exceeded | Yes, after retry-after |
| 429 | monthly_quota_exceeded | No |
| 500 / 503 | application_error, service_unavailable | Yes, with backoff |
Rate limits and monthly allowance
Requests per second depend on your plan and are shared by all of your account's keys. A batch counts as one request, so batching raises throughput a hundredfold. See Rate limits and quotas.
| Plan | Requests per second | Emails per second with batch |
|---|---|---|
| Free | 2 | up to 200 |
| Professional | 10 | up to 1,000 |
| Premium | 20 | up to 2,000 |
| Ultra | 50 | up to 5,000 |
| Enterprise | 100 | up to 10,000 |
Responses carry ratelimit-limit, ratelimit-remaining and ratelimit-reset; a 429 adds retry-after. API email shares your plan's monthly email allowance with campaigns, and each recipient (to, cc and bcc) counts as one email. Suppressed recipients and canceled emails are given back. Allowances per plan are on the pricing page.
Recipes
Password reset email
Use a sending-access key, an idempotency key per reset request (so a user who asks twice gets two emails), and a text part for clients that block HTML.
async function sendPasswordReset(user, resetRequest) { const link = `https://acme.com/reset?token=${resetRequest.token}`; const res = await fetch('https://api.cresca.xyz/api/v1/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_SENDING_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': `password-reset/${resetRequest.id}`, }, body: JSON.stringify({ from: 'Acme Security <security@acme.com>', to: user.email, subject: 'Reset your Acme password', html: `<p><a href="${link}">Reset your password</a>. The link expires in 1 hour.</p>`, text: `Reset your password: ${link} (expires in 1 hour)`, tags: [{ name: 'category', value: 'password_reset' }], }), }); const body = await res.json(); if (!res.ok) throw new Error(`${body.name}: ${body.message}`); return body.id; }
Order receipt with a template
Design the receipt once, publish it with an alias, and send only values. from and subject fall back to the template defaults; variables are strings or numbers. Building templates is covered in the email templates API guide and Templates and variables.
res = requests.post( "https://api.cresca.xyz/api/v1/emails", headers={"Authorization": f"Bearer {API_KEY}", "Idempotency-Key": f"order-receipt/{order['id']}"}, json={ "to": order["email"], "template": { "id": "order-receipt", "variables": {"ORDER_ID": order["id"], "TOTAL": order["total"]}, }, "tags": [{"name": "category", "value": "receipt"}], }, timeout=10, )
A missing variable without a fallback returns 422 validation_error ("Missing template variable"), and nothing is sent.
Scheduled reminder, then cancel
Schedule a trial reminder; if the user upgrades first, cancel it. Canceling needs a full-access key.
# 1. Schedule (store the returned id) curl -X POST https://api.cresca.xyz/api/v1/emails \ -H "Authorization: Bearer cr_live_xxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <hello@acme.com>", "to": "ana@example.com", "subject": "Your trial ends tomorrow", "text": "Your Acme trial ends tomorrow.", "scheduled_at": "2026-11-02T09:00:00Z" }' ,[object Object],curl -X POST ,[object Object], ,[object Object], -H "Authorization: Bearer cr_live_xxxxxxxxx"
Safe retries with idempotency
Retry network errors, 429 and 5xx with backoff, reusing the same key, so a retry after a dropped connection never sends twice:
async function sendWithRetry(email, key, attempts = 5) { for (let i = 0; i < attempts; i++) { try { const res = await fetch('https://api.cresca.xyz/api/v1/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_SENDING_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, }, body: JSON.stringify(email), }); const retryable = res.status === 429 || res.status >= 500 || res.status === 409; if (!retryable) return { status: res.status, body: await res.json() }; const body = await res.json(); if (body.name === 'invalid_idempotent_request' || body.name === 'monthly_quota_exceeded') { return { status: res.status, body }; } const wait = Number(res.headers.get('retry-after')) * 1000 || 2 ** i * 1000; await new Promise((r) => setTimeout(r, wait + Math.random() * 250)); } catch { await new Promise((r) => setTimeout(r, 2 ** i * 1000 + Math.random() * 250)); } } throw new Error('Cresca send failed after retries'); }
Frequently asked questions
Is there an official Cresca SDK?
No. The Emails API is plain HTTPS and JSON, so any HTTP client works: cURL, the built-in fetch in Node.js 18 and later, requests in Python, or the standard client in any other language. The field names follow common email API conventions, so existing code that builds a from, to, subject and html payload usually needs only small changes.
How do I send email with API calls from a frontend app?
Do not call the API from the browser, because that exposes your key. Have the frontend call your own backend, which sends the email with a sending-access key stored in an environment variable. Limit that key to one verified domain if it only ever sends from that domain, and revoke it on the API keys page if it leaks.
What happens if a recipient is on the suppression list?
That recipient is skipped and recorded as suppressed, other recipients still receive the email, and the request returns 200 with an id. If every recipient is suppressed, the email status is suppressed. Suppressed recipients do not count toward your monthly allowance, so you are not charged for mail that was never sent.
Can I schedule a batch?
No. scheduled_at and attachments are not supported in POST /emails/batch, and a batch item that includes either one fails the whole request with 422 validation_error. To schedule many emails, send them individually with scheduled_at, staying within your plan's requests per second, and keep each id so you can reschedule or cancel later.
How long can I look up an email after sending?
Emails and their events are kept for 30 days, then deleted. Attachment contents are deleted as soon as the email is sent. If you need a longer record, store the id, recipient, subject and the last_event you read from GET /emails/:id in your own database, since lookups after 30 days return 404 not_found.
Does a 200 response mean the email was delivered?
No. 200 means Cresca accepted and queued the email. Delivery happens in the background within seconds, and the result shows up as last_event: sent, then delivered, bounced or another status. Poll GET /emails/:id, or check the dashboard timeline, if your app needs to know the outcome. Good domain authentication helps; see the Gmail and Yahoo sender requirements.
Start sending
Verify a domain, create a sending-access key and make your first send email API call in a few minutes with the quickstart. Managing your audience from code is covered in the contacts API guide. Webhooks for delivery events are on the roadmap.
Try Cresca free for 30 days, then follow the quickstart or browse the full API docs.
Continue learning
Related Cresca resources
External references