DEV Community

Cover image for Transactional Email API: The Complete Cresca Guide
Lalit Kumar Jangid
Lalit Kumar Jangid

Posted on Originally published at cresca.xyz

Transactional Email API: The Complete Cresca Guide

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
Enter fullscreen mode Exit fullscreen mode

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>"
  }'
Enter fullscreen mode Exit fullscreen mode
// 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(`${data.name}: ${data.message}`);
console.log(data.id);
Enter fullscreen mode Exit fullscreen mode
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"])
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
{
  "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": []
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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>" }
  ]'
Enter fullscreen mode Exit fullscreen mode
const { data } = await cresca('POST', '/emails/batch', emails); // array of up to 100 emails
Enter fullscreen mode Exit fullscreen mode
{
  "data": [
    { "id": "b6d1f2a0-5e2f-4c8e-9a51-0c8b5f1d2e33" },
    { "id": "0f7e3c11-9b2a-4f6d-8e44-7a1c2d3e4f55" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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(
    "https://api.cresca.xyz/api/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"},
    params={"limit": 1},
    timeout=30,
)
print(response.json())
Enter fullscreen mode Exit fullscreen mode
{
  "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": []
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 'https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel' \
  -H 'Authorization: Bearer cr_live_xxxxxxxxx'
Enter fullscreen mode Exit fullscreen mode
await cresca('PATCH', `/emails/${id}`, { scheduled_at: '2026-10-21T09:00:00Z' });
await cresca('POST', `/emails/${id}/cancel`);
Enter fullscreen mode Exit fullscreen mode

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 'https://api.cresca.xyz/api/v1/templates/password-reset/publish' \
  -H 'Authorization: Bearer cr_live_xxxxxxxxx'
Enter fullscreen mode Exit fullscreen mode
const { id } = await cresca('POST', '/templates', { name: 'Welcome', alias: 'welcome', html: '<p>Hi {{{NAME}}}</p>' });
await cresca('POST', `/templates/${id}/publish`);
Enter fullscreen mode Exit fullscreen mode

Create returns 201 with { "object": "template", "id": "6713b1f4c2a94e0012ab34cd" }. Publish returns the new version number:

{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "version": 1 }
Enter fullscreen mode Exit fullscreen mode

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: '...' }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
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');
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
import os
import requests

response = requests.post(
    "https://api.cresca.xyz/api/v1/contacts",
    headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"},
    json={
        "list_name": "Newsletter signups",
        "contacts": [{"email": "ana@example.com", "first_name": "Ana"}, "ben@example.com"],
    },
    timeout=30,
)
print(response.json())
Enter fullscreen mode Exit fullscreen mode
{
  "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": []
}
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

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.


Top comments (0)