Developers

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 permissionCan call
Sending accessPOST /emails and POST /emails/batch. Can be limited to one verified domain.
Full accessEvery 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.

FieldTypeNotes
fromstringRequired unless the template has a default sender. hello@acme.com or Acme <hello@acme.com>.
tostring | string[]Required. Up to 50 recipients in total with cc and bcc.
subjectstringRequired unless the template has a default subject. One line, up to 998 characters.
htmlstringHTML body. Provide html, text or template.
textstringPlain-text body. Generated from html if omitted; send an empty string to leave it out.
cc, bccstring | string[]Copy and blind-copy recipients.
reply_tostring | string[]Defaults to the sender's Reply-To, if set.
templateobject{ id, variables }: a published template id or alias. Cannot be combined with html or text.
scheduled_atstringISO 8601 time up to 30 days ahead. A past time sends now.
tagsobject[]Up to 10 { name, value } pairs: ASCII letters, numbers, _ and -.
headersobjectUp to 50 custom headers. Headers Cresca controls (From, To, Subject, Message-ID...) are rejected.
attachmentsobject[]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, requests

res = 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_eventMeaning
queuedAccepted, waiting to send.
scheduledWaiting for scheduled_at. Can be rescheduled or canceled.
sendingBeing handed to the mail infrastructure.
sentHanded over for delivery.
deliveredThe recipient's mail server accepted it.
delivery_delayedDelivery is being retried.
bouncedThe recipient's server rejected it.
complainedThe recipient marked it as spam.
failedCould not be sent; the dashboard shows why.
suppressedNot sent: every recipient is suppressed.
canceledCanceled 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.

SituationResult
Same key, same body, within 24 hoursThe original response with header idempotent-replayed: true. Nothing is re-sent.
Same key, different body409 invalid_idempotent_request
Same key while the first request runs409 concurrent_idempotent_requests. Retry shortly.
First request failedThe key is released; the retry runs normally.
Key shorter than 1 or longer than 256 characters400 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." }
StatusnameRetry?
401missing_api_key, restricted_api_keyNo
403invalid_api_key, invalid_from_addressNo
404not_foundNo
409invalid_idempotent_requestNo
409concurrent_idempotent_requestsYes, shortly
413validation_error (body over 10 MB)No
422missing_required_field, validation_error, invalid_attachmentNo
429rate_limit_exceededYes, after retry-after
429monthly_quota_exceededNo
500 / 503application_error, service_unavailableYes, 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.

PlanRequests per secondEmails per second with batch
Free2up to 200
Professional10up to 1,000
Premium20up to 2,000
Ultra50up to 5,000
Enterprise100up 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