DEV Community

Cover image for Transactional email checklist: 8 things to get right before your app sends a password reset
Lalit Kumar Jangid
Lalit Kumar Jangid

Posted on Originally published at cresca.xyz

Transactional email checklist: 8 things to get right before your app sends a password reset

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

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

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

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

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

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

A canceled email doesn't count toward your monthly allowance.

The short version

  1. Send from your own domain, ideally a subdomain.
  2. Keep keys on the server, sending-only in production.
  3. Say how long links last and include plain text.
  4. Use templates with variables, not HTML strings.
  5. Send an Idempotency-Key built from the event.
  6. Retry only what can succeed, with backoff.
  7. Keep unsubscribes out of your transactional suppression.
  8. 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?

Top comments (2)

Collapse
 
beusebiu profile image
Eusebiu Balan •

Support replies were the case I had to decide on purpose. When I answer someone's message, the email can still go out even if they switched all email off, because they wrote to me first, and it carries no unsubscribe link. A bounced address still stops it, same as your hard-bounce rule.

Collapse
 
lalitkumarjangid profile image
Lalit Kumar Jangid •

That's a great case to add, and I think you've drawn the line in the right place. When someone writes to you first, a direct reply is part of a conversation they started, not an unsolicited marketing email.

Two things I'd keep watching:

  • Keep the reply focused on their question. Once you add a newsletter footer or a "while you're here, check this out" pitch, it starts crossing into marketing territory, and the relevant consent and unsubscribe rules apply.
  • Respect spam complaints, not just hard bounces. If someone marks a reply as spam, that signal should be taken seriously and handled by your suppression logic.

That's how we approach it with Cresca, too: marketing unsubscribes don't automatically block legitimate transactional emails, but hard bounces, repeated soft bounces, and spam complaints are handled by our suppression rules. We also recheck eligibility right before each email is sent.

Thanks for bringing this up. "Replies to a message the person sent" definitely deserves consideration as point 9!