Most apps send their first transactional email the same way: a developer adds a "Forgot password" flow at 1 a.m., pastes an HTML string into the code and moves on. It works, until a reset lands in spam, a customer gets two receipts, or someone who unsubscribed from your newsletter can't log in.
Transactional email is the email a person triggers and is waiting for: password resets, verification codes, receipts, invites, booking reminders. Here's the checklist I wish I'd had, with examples using the Cresca transactional email API. The ideas apply to any provider.
1. Send from your own domain, ideally a subdomain
An email from hello@yourapp.com with SPF and DKIM in place gets trusted. An email from a shared provider address gets questioned.
Use a subdomain for app email, such as mail.yourapp.com. Its reputation stays separate from your marketing campaigns, so a newsletter that gets a few spam complaints doesn't drag your password resets down with it. A DMARC record on the root domain is worth adding too.
In Cresca you add the domain under Sending Domains, publish the DNS records it shows you (Cresca can publish them for you if your DNS is on Cloudflare), and add a sender like hello@mail.yourapp.com.
2. Keep the API key on the server, with the least access it needs
Your API key can send email as your company. It belongs in a server environment variable, never in browser or mobile code.
Give production servers a key that can only send. Cresca has two permissions: sending access (send emails and batches, nothing else, optionally limited to one domain) and full access (also read emails, cancel them and manage templates). Keep the full-access key for admin scripts.
3. Send the email in one request
Here's a password reset from Node.js 18+, with no SDK:
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({
from: 'YourApp <hello@mail.yourapp.com>',
to: 'user@example.com',
subject: 'Reset your password',
html: '<p><a href="https://yourapp.com/reset?token=abc">Reset your password</a>. This link expires in 1 hour.</p>',
}),
});
const { id } = await response.json(); // save it to look the email up later
The API returns as soon as the email is accepted and sends it within seconds. Two details for the email itself: say how long the link is valid, and include a plain-text version. If you leave out text, Cresca generates one from your HTML.
4. Use templates, not HTML strings in your code
HTML strings scattered across a codebase are hard to review and easy to break. Design the email once as a template, mark the parts your app fills in, and send only the values:
body: JSON.stringify({
to: 'user@example.com',
template: {
id: 'order-receipt',
variables: {
NAME: 'Ana',
ORDER_NUMBER: '1042',
ORDER_TOTAL: '$42.00',
},
},
}),
In Cresca the template is designed in a visual editor (or created through the API) with placeholders like {{{ORDER_NUMBER}}}, then published. Sends always use the published version, so editing a draft never changes the emails your app is sending. Values are HTML-escaped, so a customer named <b>Ana</b> can't change your markup, and a missing variable with no fallback returns an error instead of sending a broken email.
5. Make retries safe
Networks fail. If the connection drops before the response arrives, you can't tell whether the email was accepted, and a blind retry means two receipts.
Send an Idempotency-Key header built from the event that caused the email:
'Idempotency-Key': `order-receipt/${order.id}`
With Cresca, the same key and body within 24 hours returns the original response with an idempotent-replayed: true header, and nothing is sent again. The same key with a different body returns 409 invalid_idempotent_request, and if the first request failed, the key is released so your retry goes through.
Don't build the key from a timestamp or a fresh UUID inside your retry loop, or every retry looks like a new email. One exception: a person who asks for a password reset twice should get two emails, so give each reset request its own key.
6. Retry the right errors, and back off
Not every failure should be retried. Retry timeouts, network errors, 5xx responses and 429 rate_limit_exceeded, waiting for the retry-after header if there is one. Don't retry other 4xx errors: a bad address or an unverified sender won't fix itself.
Every Cresca error has the same shape, so branch on name, not on the message text:
{
"statusCode": 403,
"name": "invalid_from_address",
"message": "The `from` address hello@mail.yourapp.com is not an active sender on a verified domain. Add it under Sending domains in Cresca."
}
Rate limits are per account, per second: 2 on the Free plan, up to 100 on Enterprise. If you need to send many emails at once, a batch request carries up to 100.
7. Don't let an unsubscribe block a password reset
An unsubscribe means "stop sending me marketing". It doesn't mean "never let me log in again". If your app email and your newsletter share one suppression list, someone who unsubscribed last year can't reset their password today.
Cresca's API ignores marketing unsubscribes for transactional email, while campaigns always respect them. Hard bounces and spam complaints still block an address everywhere, because those addresses really shouldn't get more email.
8. Know what "delivered" means, and check it
"Delivered" means the receiving mail server accepted the email. It doesn't mean it reached the inbox rather than spam: no sender can see that. So log the email id you get back, and when a customer says "I never got it", look it up:
curl 'https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c' \
-H 'Authorization: Bearer cr_live_xxxxxxxxx'
The response includes last_event: queued, sent, delivered, bounced, complained or failed, among others. The dashboard shows the full timeline per recipient. If it says delivered and the customer still can't find it, ask them to check spam, and look at your domain setup from point 1.
Bonus: scheduled emails you can cancel
Booking reminders and trial-ending notices are transactional too. Add scheduled_at (up to 30 days ahead) when you send, save the id, and cancel or move the email if the booking changes:
body: JSON.stringify({
from: 'YourApp <hello@mail.yourapp.com>',
to: 'user@example.com',
subject: 'Reminder: your call is tomorrow',
text: 'Your call is booked for 5 November at 10:00 UTC.',
scheduled_at: '2026-11-04T10:00:00Z',
}),
A canceled email doesn't count toward your monthly allowance.
The short version
- Send from your own domain, ideally a subdomain.
- Keep keys on the server, sending-only in production.
- Say how long links last and include plain text.
- Use templates with variables, not HTML strings.
- Send an
Idempotency-Keybuilt from the event. - Retry only what can succeed, with backoff.
- Keep unsubscribes out of your transactional suppression.
- Log email ids and check status when someone says "I never got it".
If you want to try this, Cresca sends transactional email and marketing campaigns from the same account and the same domain, and the API works on the Free plan. The docs have a quickstart in cURL, Node.js and Python. Webhooks and an SMTP relay are on our list; for now, delivery status comes from the API or the dashboard.
What's on your transactional email checklist that isn't on mine?












