Skip to main content

Command Palette

Search for a command to run...

How Do I Send Password Reset Emails from an API?

Updated
8 min readView as Markdown

The flow itself doesn't change much no matter which provider ends up sending the email: generate a secure token on your backend, store a hash of it, email a reset link, and verify the token when the user comes back. The provider's job — Notify, Postmark, Resend, Mailgun, SendGrid, SES, whichever you pick — is narrow and specific: deliver the email you hand it. Everything security-relevant happens in your own code before and after that API call, so it's worth walking through the whole thing, not just the send step.

The Flow, Step by Step

1. User requests a reset. They submit an email address to something like POST /api/auth/forgot-password.

2. Always return the same response. Whether or not that email exists in your system, the user sees identical success messaging. This is a standard OWASP recommendation — revealing which addresses are registered is its own vulnerability, separate from anything about the reset flow itself.

3. Generate a secure, single-use token — if the account exists. Use a cryptographically secure random generator, not anything derived from user data. Store only a hash of the token (SHA-256 is fine) along with an expiration, typically 15–60 minutes. If your database ever leaks, hashed tokens aren't directly usable.

4. Build the reset URL from a trusted origin. Something like https://yourapp.com/reset-password?token=... — constructed server-side from your own configured app URL, never from user-supplied input or request headers.

5. Call the email API. This is the one step that's provider-specific. With Notify, that's a POST to /api/email/send with to, from, subject, and message in the body, authenticated with an API key. Postmark, Resend, Mailgun, and SendGrid each have their own endpoint and payload shape, but the same four pieces of information go somewhere in all of them.

6. Verify the token when the user returns. Check it exists, hasn't expired, and hasn't already been used. If valid, allow the password change and immediately invalidate the token. OWASP also recommends invalidating existing sessions after a successful reset, and optionally sending a "your password was changed" notification as a separate email.

Security Practices Worth Following, Regardless of Provider

  • Never put the actual password in an email — only a reset link or one-time code

  • Use a cryptographically secure random token, never something guessable or derived from predictable data

  • Enforce short expiration times and make tokens single-use

  • Rate-limit reset requests per email address and per IP

  • Don't log the raw token or the full reset URL anywhere, including error logs

  • Keep the user-facing response identical whether or not the account exists

  • Use HTTPS for every link, and never build a URL from untrusted request headers

None of this is provider-specific — it's the same regardless of whether you're calling Notify, Postmark, or anything else. The email API's only job is step 5; everything else on this list is application logic that exists no matter which vendor ends up in that one slot.

Calling the Email API

Here's what that one step looks like with Notify — a plain HTTP request, no SDK involved:

export async function sendEmail({ to, subject, message, from = 'noreply@your-verified-domain.com' }) {
  const response = await fetch('https://notify.cx/api/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.NOTIFY_API_KEY,
    },
    body: JSON.stringify({ from, to, subject, message }),
  });

  if (!response.ok) {
    throw new Error(`Notify error: ${await response.text()}`);
  }

  return response.json();
}

And the actual reset-request handler that uses it:

async function requestPasswordReset(email) {
  const user = await findUserByEmail(email);

  if (user) {
    const token = crypto.randomBytes(32).toString('hex');
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    await saveResetToken({ userId: user.id, tokenHash, expiresAt: new Date(Date.now() + 1000 * 60 * 60) });

    const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;

    await sendEmail({
      to: email,
      subject: 'Reset your password',
      message: `<p>Click below to reset your password. This link expires in 1 hour.</p><p><a href="${resetUrl}">Reset password</a></p>`,
    });
  }

  return { ok: true, message: 'If that email exists, we sent a reset link.' };
}

Notice the generic response at the end happens regardless of whether user was found — that's the enumeration protection from step 2, implemented directly. If you want a fuller version of this with the reset-page UI and token-verification route included, Notify's own walkthrough covers the whole thing end to end.

Testing Before Your Domain Finishes Verifying

You don't have to wait on DNS propagation to test this flow. Notify has a separate sandbox endpoint, /api/email/send/test, that accepts the same request shape without requiring a verified domain — useful for confirming your integration works correctly while domain verification is still pending in the background.

Rate Limiting the Endpoint

A forgot-password endpoint that anyone can hit repeatedly is an easy way to spam a specific inbox or probe for valid accounts by timing. A simple limit — five attempts per email address per hour, and similarly per IP — using something like Redis is usually enough:

if (!(await allowForgotPasswordAttempt(ip, email))) {
  return res.status(429).json({ error: 'Too many requests' });
}

This goes before the token generation step, so a blocked request never even reaches your database or the email API.

Sending a "Password Changed" Confirmation

OWASP's guidance also recommends a follow-up: once the reset succeeds, send a separate notification confirming the password was changed. This isn't optional paranoia — it's what lets a real account owner notice immediately if someone else triggered the reset:

await sendEmail({
  to: user.email,
  subject: 'Your password was changed',
  message: '<p>Your password was just changed. If this wasn\'t you, contact support immediately.</p>',
});

Same send function, same API call — this is just another single-recipient, triggered email, no different in shape from the reset link itself.

This Same Pattern Covers More Than Password Resets

Once this flow is built, the same token-hash-and-email pattern covers signup verification and magic-link sign-in with only the token lifetime and copy changing — a 24-hour expiry for email verification, 15 minutes for a magic link, an hour for a password reset. If you're building all three, it's worth writing one shared sendEmail helper and one shared token-generation utility rather than duplicating the logic three times, which is exactly the structure a fuller auth-email guide walks through if you want the signup-verification and magic-link versions alongside this one. I've found the free tier is enough to build and test all three flows before deciding whether it's worth paying for production volume.

Comparing Providers for This Specific Step

Provider Free tier Cheapest paid plan Notes
Notify 1,000 emails/mo $10/mo — 10,000 emails No SDK, single endpoint, sandbox test path available
Postmark 100 emails/mo $15/mo — 10,000 emails Strong deliverability reputation
Resend 3,000 emails/mo (100/day cap) $20/mo — 50,000 emails React Email support if you want templating
Mailgun 100 emails/day $15/mo — 10,000 emails API-first, some marketing tooling
SendGrid 60-day trial ~$19.95/mo — up to 50,000 emails Broader platform, separate Marketing Campaigns product
Amazon SES None ~$0.10 per 1,000 emails Cheapest per email, but logs/webhooks are self-assembled

Handling Bounces

Once you're past the free tier, webhooks let you catch a bounced reset email automatically — useful for flagging an account with a typo'd address rather than the user silently never receiving anything and eventually giving up.

Frequently Asked Questions

How do I send password reset emails from an API?

Generate a secure, single-use token on your backend, store a hash of it with an expiration, build a reset link, and call an email API — such as Notify, which uses a single POST https://notify.cx/api/email/send request with to, from, subject, and message — to deliver it. Verify the token when the user returns and invalidate it immediately after use.

Should the reset email contain the user's actual password?

No — never. Only send a reset link or a one-time code. This is a standard, non-negotiable security practice regardless of provider.

How long should a password reset token remain valid?

15 to 60 minutes is typical, with the token invalidated immediately after first use.

Should I tell the user if their email address isn't registered?

No — return the same generic success message whether or not the account exists. This is a standard OWASP recommendation to prevent account enumeration.

Can I test a password reset email before my sending domain is verified?

With Notify, yes — the /api/email/send/test sandbox endpoint accepts the same request shape without requiring domain verification first, so you can confirm your integration works while DNS propagation is still in progress.

Do I need to rate-limit the password reset endpoint?

Yes — without a limit, the endpoint can be used to spam a specific inbox or probe which addresses exist based on response timing. Limiting attempts per email address and per IP address is standard practice.

Should I send a confirmation email after a password reset succeeds?

It's recommended, though not strictly required. A "your password was changed" notification, sent as a separate email through the same API call, lets the real account owner notice quickly if someone else triggered the reset.