Developers

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.

MethodPathPurposeKeyDocs
POST/emailsSend one email (up to 50 recipients)SendingSend email
POST/emails/batchSend up to 100 emails in one requestSendingSend batch
GET/emails/:idRetrieve one email and its last_eventFullRetrieve email
GET/emailsList emails, newest firstFullList emails
PATCH/emails/:idReschedule a scheduled emailFullUpdate email
POST/emails/:id/cancelCancel a scheduled or queued emailFullCancel email
POST/templatesCreate a template draftFullCreate template
GET/templates/:idRetrieve a template by id or aliasFullRetrieve template
GET/templatesList templatesFullList templates
PATCH/templates/:idUpdate the draftFullUpdate template
POST/templates/:id/publishPublish the draft as the next versionFullPublish template
DELETE/templates/:idDelete a templateFullDelete template
POST/contactsAdd up to 1,000 contacts to a listFullAdd 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.

PermissionCan doUse it for
Sending accessSend emails and batches. Can be limited to one verified domain.Production servers
Full accessEverything: send, read and cancel emails, manage templates, add contactsScripts 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.

FieldTypeRequiredNotes
fromstringYes*hello@acme.com or Acme <hello@acme.com>; an active sender
tostring or string[]YesUp to 50 recipients across to, cc and bcc
subjectstringYes*Single line, up to 998 characters
html / textstringOne of html, text, templateText is generated from HTML if omitted; "" leaves it out
cc, bcc, reply_tostring or string[]Noreply_to defaults to the sender's Reply-To
templateobjectNo{ "id", "variables" }; cannot be combined with html or text
scheduled_atstringNoISO 8601, up to 30 days ahead; a past time sends now
tagsobject[]NoUp to 10 { "name", "value" } pairs
headersobjectNoUp to 50 custom headers; Cresca-controlled ones are refused
attachmentsobject[]NoUp 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 requests

response = 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.

FieldRequiredNotes
nameYesUp to 120 characters, unique in your account
htmlYesBody with {{{VARIABLE}}} placeholders
aliasNoLowercase letters, numbers, dashes, underscores; up to 64
subject, text, from, reply_to, preview_textNoDefaults used at send time
variablesNoUp 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.

FieldTypeRequiredNotes
list_namestringYesCreated if it does not exist
contacts(string | object)[]Yes1 to 1,000 addresses or { "email", "first_name", "last_name" }
import os import requests

response = 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_eventMeaning
queued / scheduledAccepted, waiting to send (or to its scheduled_at)
sending / sentBeing handed to, then accepted by, the mail infrastructure
deliveredThe recipient's server accepted it
delivery_delayedDelivery is being retried
bounced / complainedRejected, or marked as spam
failed / suppressed / canceledNot 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

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