Transactional Email API: The Complete Cresca Guide
By Lalit Kumar Jangid15 min
On this page+
Cresca's transactional email API is a REST API at https://api.cresca.xyz/api/v1 that sends one-to-one email, such as password resets, receipts and verification codes, from your own verified domain. You authenticate with an API key in an Authorization: Bearer header, POST JSON to /emails, and get back an id you can use to check delivery. This guide covers every v1 endpoint, with a full worked example and code you can copy.
In this guide
- Every endpoint at a glance
- Authentication: API keys and the Bearer header
- Worked example: your first email, end to end
- Emails API: send, batch, list, reschedule, cancel
- Templates API: create, publish and send by alias
- Contacts API
- Idempotency, lifecycle, suppression, limits and errors
- Frequently asked questions
All requests go to https://api.cresca.xyz/api/v1 over HTTPS, and requests and responses are JSON. There is no SDK package to install: any HTTP client works, so the examples below use cURL, Node.js 18+ (built-in fetch) and Python (requests). Field names follow the conventions of popular email APIs, so an existing integration is mostly a change of base URL and key.
The API is for transactional email: messages a person expects because of something they did. Newsletters and promotions belong in Cresca campaigns. If the line is unclear, read transactional vs marketing email.
Every endpoint at a glance
Paths are relative to the base URL. "Sending" means a sending-access key works; "Full" needs a full-access key.
| Method | Path | Purpose | Key | Docs |
|---|---|---|---|---|
| POST | /emails | Send one email (up to 50 recipients) | Sending | Send email |
| POST | /emails/batch | Send up to 100 emails in one request | Sending | Send batch |
| GET | /emails/:id | Retrieve one email and its last_event | Full | Retrieve email |
| GET | /emails | List emails, newest first | Full | List emails |
| PATCH | /emails/:id | Reschedule a scheduled email | Full | Update email |
| POST | /emails/:id/cancel | Cancel a scheduled or queued email | Full | Cancel email |
| POST | /templates | Create a template draft | Full | Create template |
| GET | /templates/:id | Retrieve a template by id or alias | Full | Retrieve template |
| GET | /templates | List templates | Full | List templates |
| PATCH | /templates/:id | Update the draft | Full | Update template |
| POST | /templates/:id/publish | Publish the draft as the next version | Full | Publish template |
| DELETE | /templates/:id | Delete a template | Full | Delete template |
| POST | /contacts | Add up to 1,000 contacts to a list | Full | Add contacts |
Authentication: API keys and the Bearer header
Create keys on the API keys page of the Cresca app. Keys start with cr_live_ and are shown once; Cresca stores only a hash. Send the key as a bearer token:
Authorization: Bearer cr_live_xxxxxxxxx
The X-API-Key header also works. Keep keys on your server, never in browser code, URLs or public repositories.
| Permission | Can do | Use it for |
|---|---|---|
| Sending access | Send emails and batches. Can be limited to one verified domain. | Production servers |
| Full access | Everything: send, read and cancel emails, manage templates, add contacts | Scripts and internal tools |
Authentication errors: 401 missing_api_key (no key), 403 invalid_api_key (unknown or revoked key, or suspended account) and 401 restricted_api_key (a sending-access key called a full-access endpoint). Details are on the Authentication docs page.
Worked example: send your first transactional email
This is the whole path from a fresh account to a delivered email. It mirrors the quickstart.
Step 1: Verify a sending domain
In the app, open Sending Domains, add your domain and publish the DNS records Cresca shows you: DKIM, a custom MAIL FROM domain and an ownership record. If your DNS is on Cloudflare, Cresca can publish them for you. The domain becomes active once the records verify. A subdomain such as mail.acme.com keeps the reputation of app email separate from campaigns. See sending domains and SPF, DKIM and DMARC done right.
Step 2: Add a sender
On the same page, create a sender such as hello@acme.com with a display name and an optional Reply-To. Every API from address must be an active sender on a verified domain.
Step 3: Create an API key
Create a Sending access key for your server and store it as CRESCA_API_KEY. Create a separate full-access key for the status check in step 5.
Step 4: Send the email
The Idempotency-Key header makes the request safe to retry.
curl -X POST 'https://api.cresca.xyz/api/v1/emails' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: password-reset/reset-request-9f2c' \ -d '{ "from": "Acme <hello@acme.com>", "to": "user@example.com", "subject": "Reset your password", "html": "<p>Click <a href=\"https://acme.com/reset?token=abc\">here</a> to reset your password.</p>" }'
// Node.js 18+, run as an ES module for top-level await const response = 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': 'password-reset/reset-request-9f2c', }, body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: 'user@example.com', subject: 'Reset your password', html: '<p>Click <a href="https://acme.com/reset?token=abc">here</a> to reset your password.</p>', }), });const data = await response.json(); if (!response.ok) throw new Error(,[object Object],); console.log(data.id);
import os import requests response = requests.post( "https://api.cresca.xyz/api/v1/emails", headers={ "Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}", "Idempotency-Key": "password-reset/reset-request-9f2c", }, json={ "from": "Acme <hello@acme.com>", "to": "user@example.com", "subject": "Reset your password", "html": '<p>Click <a href="https://acme.com/reset?token=abc">here</a> to reset your password.</p>', }, timeout=30, ) response.raise_for_status() print(response.json()["id"])
A 200 response returns the id as soon as the email is accepted; sending happens in the background within seconds.
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
Step 5: Check the status
Retrieve the email with your full-access key, or open Transactional in the app for the full timeline.
curl 'https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx'
{ "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", "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": [] }
last_event moves from queued through sending and sent to delivered (or bounced). Emails are kept for 30 days; another account's id returns 404 not_found.
Emails API
The email sending API has six endpoints. POST /emails takes these fields; the deep dive is in the send email API guide.
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | Yes* | hello@acme.com or Acme <hello@acme.com>; an active sender |
to | string or string[] | Yes | Up to 50 recipients across to, cc and bcc |
subject | string | Yes* | Single line, up to 998 characters |
html / text | string | One of html, text, template | Text is generated from HTML if omitted; "" leaves it out |
cc, bcc, reply_to | string or string[] | No | reply_to defaults to the sender's Reply-To |
template | object | No | { "id", "variables" }; cannot be combined with html or text |
scheduled_at | string | No | ISO 8601, up to 30 days ahead; a past time sends now |
tags | object[] | No | Up to 10 { "name", "value" } pairs |
headers | object | No | Up to 50 custom headers; Cresca-controlled ones are refused |
attachments | object[] | No | Up to 20, 7 MB total, base64 content and filename |
*Optional when a template supplies a default. Unknown fields are ignored. Attaching by URL (path) is not supported.
The shorter Node.js examples below use this helper:
const cresca = (method, path, body) => fetch(`https://api.cresca.xyz/api/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }).then((res) => res.json());
Send a batch
POST /emails/batch takes a JSON array of up to 100 emails with the same fields, except attachments and scheduled_at. A batch is all or nothing: if one email is invalid, nothing is sent and the message names it, for example Email 3: ... (zero-based).
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-digest/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 { data } = await cresca('POST', '/emails/batch', emails); // array of up to 100 emails
{ "data": [ { "id": "b6d1f2a0-5e2f-4c8e-9a51-0c8b5f1d2e33" }, { "id": "0f7e3c11-9b2a-4f6d-8e44-7a1c2d3e4f55" } ] }
List emails
GET /emails accepts limit (1 to 100, default 20) and either after or before (an email id). Items leave out html and text.
import os import requestsresponse = requests.get( ",[object Object],", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"}, params={"limit": 1}, timeout=30, ) print(response.json())
{ "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": [] } ] }
For the next page, pass the last item's id as after while has_more is true.
Reschedule and cancel
PATCH /emails/:id takes one required field, scheduled_at, and only works while the status is scheduled. POST /emails/:id/cancel works while the email is scheduled or queued; a canceled email does not count toward your allowance.
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" }'curl -X POST ',[object Object],' ,[object Object], -H 'Authorization: Bearer cr_live_xxxxxxxxx'
await cresca('PATCH', `/emails/${id}`, { scheduled_at: '2026-10-21T09:00:00Z' }); await cresca('POST', `/emails/${id}/cancel`);
Both return { "object": "email", "id": "4ef9a417-..." }. Rescheduling an email that is not scheduled, or canceling one that is already sending, returns 422 validation_error.
Common Emails API errors: 403 invalid_from_address (not an active sender, 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), 422 invalid_attachment, 429 monthly_quota_exceeded and 404 not_found.
Templates API
Templates let you design an email once, publish it, and send it by alias with variables written as {{{NAME}}}. Edits change the draft; sends always use the published version. All six endpoints need a full-access key, and :id accepts the id or the alias. More depth: the email templates API guide and Templates and variables.
| Field | Required | Notes |
|---|---|---|
name | Yes | Up to 120 characters, unique in your account |
html | Yes | Body with {{{VARIABLE}}} placeholders |
alias | No | Lowercase letters, numbers, dashes, underscores; up to 64 |
subject, text, from, reply_to, preview_text | No | Defaults used at send time |
variables | No | Up to 50 { "key", "type", "fallback_value" }; type is string or number |
Create and publish
curl -X POST 'https://api.cresca.xyz/api/v1/templates' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' \ -H 'Content-Type: application/json' \ -d '{ "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <hello@acme.com>", "html": "<p>Hi {{{NAME}}},</p><p><a href=\"{{{RESET_LINK}}}\">Reset your password</a>.</p>", "variables": [ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string" } ] }'curl -X POST ',[object Object],' ,[object Object], -H 'Authorization: Bearer cr_live_xxxxxxxxx'
const { id } = await cresca('POST', '/templates', { name: 'Welcome', alias: 'welcome', html: '<p>Hi {{{NAME}}}</p>' }); await cresca('POST', `/templates/${id}/publish`);
Create returns 201 with { "object": "template", "id": "6713b1f4c2a94e0012ab34cd" }. Publish returns the new version number:
{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "version": 1 }
Send by alias with variables
const response = await fetch('https://api.cresca.xyz/api/v1/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ to: 'user@example.com', template: { id: 'password-reset', variables: { NAME: 'Ana', RESET_LINK: 'https://acme.com/reset?token=abc' }, }, }), }); console.log(await response.json()); // { id: '...' }
Subject and sender come from the template unless your request sets them. Values are HTML-escaped in the HTML body. A variable with no value and no fallback stops the send with 422 validation_error naming it; an unpublished template also returns 422.
Retrieve, list, update and delete
curl 'https://api.cresca.xyz/api/v1/templates/password-reset' -H 'Authorization: Bearer cr_live_xxxxxxxxx' curl 'https://api.cresca.xyz/api/v1/templates?limit=10' -H 'Authorization: Bearer cr_live_xxxxxxxxx' curl -X PATCH 'https://api.cresca.xyz/api/v1/templates/password-reset' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' -H 'Content-Type: application/json' \ -d '{ "subject": "Reset your Acme password" }' curl -X DELETE 'https://api.cresca.xyz/api/v1/templates/password-reset' -H 'Authorization: Bearer cr_live_xxxxxxxxx'
await cresca('GET', '/templates/password-reset'); await cresca('GET', '/templates?limit=10'); await cresca('PATCH', '/templates/password-reset', { subject: 'Reset your Acme password' }); await cresca('DELETE', '/templates/password-reset');
Retrieve returns the template object (list wraps the same objects in { "object": "list", "has_more", "data" }):
{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <hello@acme.com>", "reply_to": null, "preview_text": null, "html": "<p>Hi {{{NAME}}},</p><p><a href=\"{{{RESET_LINK}}}\">Reset your password</a>.</p>", "text": null, "variables": [ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string", "fallback_value": null } ], "status": "published", "version": 1, "published_at": "2026-10-19T08:30:00.000Z", "created_at": "2026-10-18T16:02:11.000Z", "updated_at": "2026-10-19T08:30:00.000Z" }
Update returns { "object": "template", "id" } and changes only the draft; send null for alias to remove it. Delete returns "deleted": true, and later sends that reference the template fail with 404 not_found. Typical errors: 422 missing_required_field (no name or html) and 422 validation_error (duplicate name or alias, bad variable).
Contacts API
POST /contacts adds up to 1,000 contacts per request to a list for your marketing campaigns, creating the list if needed. It needs a full-access key and is limited to 120 requests per 15 minutes. Full walkthrough: the contacts API guide.
| Field | Type | Required | Notes |
|---|---|---|---|
list_name | string | Yes | Created if it does not exist |
contacts | (string | object)[] | Yes | 1 to 1,000 addresses or { "email", "first_name", "last_name" } |
import os import requestsresponse = requests.post( ",[object Object],", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"}, json={ "list_name": "Newsletter signups", "contacts": [{"email": ",[object Object],", "first_name": "Ana"}, ",[object Object],"], }, timeout=30, ) print(response.json())
{ "object": "contact_import", "list": { "id": "6713b2a8c2a94e0012ab3511", "name": "Newsletter signups", "created": false }, "summary": { "received": 2, "created": 1, "updated": 0, "already_present": 1, "rejected": 0, "added_to_list": 1, "limit_reached": false }, "errors": [] }
Invalid, disposable, role, suppressed and duplicate addresses are listed in errors with their index; the rest are still added. An inactive plan returns 403 plan_inactive; a missing list_name returns 422 missing_required_field.
Idempotency, lifecycle, suppression, limits and errors
Idempotency
Send an Idempotency-Key (1 to 256 characters) on POST /emails or /emails/batch. The same key and body within 24 hours return the original response with idempotent-replayed: true and nothing is sent again. The same key with a different body returns 409 invalid_idempotent_request; a key still in flight returns 409 concurrent_idempotent_requests. A failed request releases its key. Derive keys from the event, such as invoice/inv-2026-0042. See Idempotency.
Email lifecycle: statuses and events
| last_event | Meaning |
|---|---|
queued / scheduled | Accepted, waiting to send (or to its scheduled_at) |
sending / sent | Being handed to, then accepted by, the mail infrastructure |
delivered | The recipient's server accepted it |
delivery_delayed | Delivery is being retried |
bounced / complained | Rejected, or marked as spam |
failed / suppressed / canceled | Not sent, every recipient suppressed, or canceled |
Status only moves forward, while last_event is the latest event. delivered means the receiving server accepted it, not that it reached the inbox. Full table: Email lifecycle.
Suppression
Hard bounces, repeated soft bounces (3 in 30 days), spam complaints and manual suppressions block API email; suppressed recipients are skipped and the others still receive it. A marketing unsubscribe does not block a password reset. If every recipient is suppressed you still get 200, and the status is suppressed. See Suppression.
Rate limits and quotas
| Plan | Requests per second | Emails per second with batches |
|---|---|---|
| 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 |
The limit is per account, shared by all keys, and an inactive plan gets the Free limit. Authenticated responses carry ratelimit-limit, ratelimit-remaining and ratelimit-reset; a 429 rate_limit_exceeded adds retry-after. API email shares your plan's monthly email allowance with campaigns, one unit per recipient; when it runs out you get 429 monthly_quota_exceeded. Request bodies are capped at 10 MB. See Rate limits and quotas and pricing.
Errors
Every error has the same shape. Branch on name, not message:
{ "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." }
Retry 429 rate_limit_exceeded, 409 concurrent_idempotent_requests, 500 application_error, 503 service_unavailable, timeouts and network errors with exponential backoff and jitter, using an Idempotency-Key. Do not retry other 4xx responses; fix the request. The full list is on Errors.
Frequently asked questions
Is there an official SDK for the Cresca email API?
No. Cresca does not publish an SDK package; the API is plain HTTPS and JSON, so cURL, Node.js fetch, Python requests or any HTTP client works. Field names and responses follow the conventions of popular email APIs, so moving an existing integration is mostly a change of base URL and API key. The docs show every request in cURL, Node.js and Python.
Can I send marketing email through the transactional email API?
No. The API is for messages a person expects because of something they did, such as receipts, password resets and alerts. Newsletters and promotions belong in Cresca campaigns, which add the unsubscribe link and sender address marketing email legally requires. Keeping them apart also protects deliverability, as explained in transactional vs marketing email.
How do I know an email was delivered?
Call GET /emails/:id with a full-access key and read last_event, or open Transactional in the Cresca app for the per-recipient timeline of sent, delivered, bounced and complained events. delivered means the receiving server accepted the email. Webhooks are on the roadmap; today you read status from the API or the dashboard.
Does API email count toward my plan?
Yes. API email uses the same monthly email allowance as your campaigns, and each recipient in to, cc and bcc counts as one email. Suppressed recipients, canceled emails and emails that fail before sending are given back. When the allowance is used up, sends return 429 monthly_quota_exceeded until the next billing period or an upgrade. See pricing.
What happens if my server retries after a timeout?
If the request carried an Idempotency-Key, the retry returns the original response with the header idempotent-replayed: true, and no second email is sent. Without a key, a retry can send a duplicate. Derive the key from the event that caused the email, such as welcome/user-123, so every retry produces the same key.
Do I need SPF, DKIM and DMARC to send transactional email?
Your from address must be on a domain verified in Cresca, which means publishing the DKIM, custom MAIL FROM and ownership records Cresca shows you. Gmail and Yahoo also expect a DMARC record from bulk senders. The Gmail and Yahoo sender requirements post lists every rule.
Start sending
A transactional email service should take minutes to set up: verify a domain, create a key, send one request. Try Cresca free for 30 days, then follow the quickstart to send your first email.
Continue learning
Related Cresca resources
External references