# Password reset email: template and code

Source: https://www.courier.com/recipes/password-reset-email (manifest: https://www.courier.com/recipes/password-reset-email/manifest.json)

Send a password reset email with a link that expires, and never deliver a stale one.

A password reset email sends a link to choose a new password when someone asks for one. It's a single email with one button, plus a line telling anyone who didn't ask that their password hasn't changed. Courier stops trying to deliver it after 30 minutes, so an expired link never arrives.

Tested 2026-10-19: passing.

## When to send a password reset email

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When someone asks to reset their password | Email | Reset your Acme password |

## Copy

### Password reset (email)

Subject: "Reset your Acme password", "Reset your password", "Your Acme password reset link"

Someone asked to reset the password for your Acme account. Use the button below to choose a new one. The link expires in 30 minutes.

If you didn't ask for this, you can ignore this email. Your password won't change.

Button: Reset your password ({{reset_url}})

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// Creates this recipe's templates in your workspace from the templates/
// folder and publishes them. Run it once per workspace:
//
//   COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
//
// It prints the template IDs that start.ts needs.
import Courier from "@trycourier/courier";
import { readFileSync, readdirSync } from "node:fs";

const client = new Courier();
const here = new URL(".", import.meta.url);

const templateIds: Record<string, string> = {};
for (const file of readdirSync(new URL("templates/", here))) {
  const body = JSON.parse(
    readFileSync(new URL(`templates/${file}`, here), "utf8")
  );
  const template = await client.notifications.create(body);
  templateIds[file.replace(/\.json$/, "")] = template.id;
}

console.log(JSON.stringify({ templateIds }));
```

## Send it when someone asks to reset their password

```typescript
// Send a password reset email with the template ID setup.ts printed. The
// timeout tells Courier to stop trying after the same 30 minutes the reset link
// works for. The template says "30 minutes" too, so change both together.
import Courier from "@trycourier/courier";

const client = new Courier();

const EXPIRES_IN_MINUTES = 30;

export async function sendPasswordReset(
  templateId: string,
  userId: string,
  resetUrl: string
) {
  const { requestId } = await client.send.message({
    message: {
      to: { user_id: userId },
      template: templateId,
      data: { reset_url: resetUrl },
      routing: { method: "single", channels: ["email"] },
      timeout: { message: EXPIRES_IN_MINUTES * 60 * 1000 },
    },
  });
  return requestId;
}
```

```python
# Send a password reset email with the template ID setup.ts printed. The
# timeout tells Courier to stop trying after the same 30 minutes the reset link
# works for. The template says "30 minutes" too, so change both together.
from courier import Courier

client = Courier()

EXPIRES_IN_MINUTES = 30


def send_password_reset(template_id: str, user_id: str, reset_url: str) -> str:
    response = client.send.message(
        message={
            "to": {"user_id": user_id},
            "template": template_id,
            "data": {"reset_url": reset_url},
            "routing": {"method": "single", "channels": ["email"]},
            "timeout": {"message": EXPIRES_IN_MINUTES * 60 * 1000},
        },
    )
    return response.request_id
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/password-reset.json

```json
{
  "notification": {
    "name": "Password reset",
    "tags": ["recipe", "password-reset-email"],
    "brand": null,
    "subscription": null,
    "routing": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Reset your Acme password"
            },
            {
              "type": "text",
              "content": "Someone asked to reset the password for your Acme account. Use the button below to choose a new one. The link expires in 30 minutes."
            },
            {
              "type": "action",
              "content": "Reset your password",
              "href": "{{reset_url}}"
            },
            {
              "type": "text",
              "content": "If you didn't ask for this, you can ignore this email. Your password won't change."
            }
          ]
        }
      ]
    }
  },
  "state": "PUBLISHED"
}
```

## Hand it to your agent

```text
Add password reset emails to this app, sent through Courier: one email with a Reset your password button, the link's expiry in the copy, and no retries after the link expires. Follow the steps at https://www.courier.com/recipes/password-reset-email.md and use the Courier skill for SDK details.

1. Create the template in my Courier workspace by running setup.ts from that page with COURIER_API_KEY from our environment. Save the template ID it prints as COURIER_PASSWORD_RESET_TEMPLATE_ID.
2. Find where this codebase creates a password reset token and send the email there with the recipe's send code. Pass only reset_url. Replace the placeholder product name (Acme) in the template with ours, and make its "30 minutes" match the token's real lifetime and the send timeout.
3. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
4. When you're done, request a reset for a test user and show me the rendered email.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| reset_url | URL | https://app.acme.com/reset-password?token=8d2f6a | Your app creates the link and its token |

## FAQ

### How do I send a password reset email?

Create a reset token in your app, build the reset URL, and pass it to the template as reset_url. The send call delivers the email. The reset page and the token check stay in your app.

### What should a password reset email include?

A clear subject line, one button that opens your reset page, how long the link works, and a line saying nothing changes if the person didn't ask. Leave out marketing, extra links, and any password, old or new.

### How long should a password reset link be valid?

Long enough to find the email, short enough to limit the risk. Thirty minutes is a reasonable default. Enforce the same limit on the token in your app, and set the message timeout and the minutes in the template to match.

### How do I know a password reset email was delivered?

The send call returns a request ID. Look it up with the Messages API to see its status, its delivery history, and the rendered email.

## Related docs

- [Send a message](https://www.courier.com/docs/send/overview)
- [Routing and fallback](https://www.courier.com/docs/send/routing)
- [Message statuses](https://www.courier.com/docs/send/statuses)

## Related recipes

- [Magic link email](https://www.courier.com/recipes/magic-link-email): Sign people in with an emailed link, and never deliver one that has already expired.
- [New device alert](https://www.courier.com/recipes/new-device-login-alert): Tell people right away when their account is used from a new device.
- [One-time passcode](https://www.courier.com/recipes/otp-sms-email-fallback): Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
