# Payment receipt email: template and code

Source: https://www.courier.com/recipes/payment-receipt-email (manifest: https://www.courier.com/recipes/payment-receipt-email/manifest.json)

Send a clean receipt after every payment, by email and in your app's Inbox.

A payment receipt email confirms a successful payment with every line item, the subtotal, tax, and total, and a link to the full receipt. It sends by email and to your app's Inbox, which keeps a receipt history. If your payment webhook is retried within four days, the customer still gets one receipt.

Tested 2026-10-19: passing.

## When to send a payment receipt email

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When a payment succeeds | Email, Inbox | Your Acme receipt for {{format "$%.2f" (divide total 100)}} |

## Copy

### Payment receipt (email)

Subject: "Your Acme receipt for {{format "$%.2f" (divide total 100)}}", "Thanks for your payment to Acme", "Receipt {{receipt_number}} from Acme"

Thanks for your payment. Here's your receipt for {{receipt_number}}, paid on {{datetime-format paid_at "MMM d, yyyy" (default profile.timezone "America/New_York")}} with {{payment_method}}.

Pro plan (monthly): $49.00

2 extra seats: $30.00

SMS add-on: $7.00

Subtotal: {{format "$%.2f" (divide subtotal 100)}}

Tax: {{format "$%.2f" (divide tax 100)}}

Total paid: {{format "$%.2f" (divide total 100)}}

Button: View receipt (https://app.acme.com/billing/receipts/{{receipt_number}})

### Payment receipt (Inbox)

Subject: "Payment received: {{format "$%.2f" (divide total 100)}}"

Receipt {{receipt_number}}, paid on {{datetime-format paid_at "MMM d, yyyy" (default profile.timezone "America/New_York")}} with {{payment_method}}.

Button: View receipt (https://app.acme.com/billing/receipts/{{receipt_number}})

## 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 a payment succeeds

```typescript
// Email a receipt and add a copy to your app's Inbox after a successful
// payment, with the template ID setup.ts printed. The receipt number is the
// idempotency key, so a retried payment webhook doesn't send a second receipt.
// Courier keeps a key for 25 hours unless you set an expiration, and Stripe
// retries a failed webhook for up to three days, so the key is held for four.
// The "receipt" tag lets an Inbox tab show receipts on your billing page. The
// templates format paidAt as a date, show every amount (integer cents, as
// Stripe sends them) as dollars, and build the receipt link from the receipt
// number.
import Courier from "@trycourier/courier";

const client = new Courier();
const DAY = 24 * 60 * 60 * 1000;

export interface Receipt {
  receiptNumber: string;
  paidAt: Date;
  paymentMethod: string;
  lineItems: { name: string; amount: number }[];
  subtotal: number;
  tax: number;
  total: number;
}

export async function sendReceipt(
  templateId: string,
  userId: string,
  receipt: Receipt
) {
  const { requestId } = await client.send.message({
    message: {
      to: { user_id: userId },
      template: templateId,
      data: {
        receipt_number: receipt.receiptNumber,
        paid_at: receipt.paidAt.toISOString(),
        payment_method: receipt.paymentMethod,
        line_items: receipt.lineItems,
        subtotal: receipt.subtotal,
        tax: receipt.tax,
        total: receipt.total,
      },
      routing: { method: "all", channels: ["email", "inbox"] },
      metadata: { tags: ["receipt"] },
    },
    "Idempotency-Key": `receipt-${receipt.receiptNumber}`,
    "x-idempotency-expiration": new Date(Date.now() + 4 * DAY).toISOString(),
  });
  return requestId;
}
```

```python
# Email a receipt and add a copy to your app's Inbox after a successful
# payment, with the template ID setup.ts printed. The receipt number is the
# idempotency key, so a retried payment webhook doesn't send a second receipt.
# Courier keeps a key for 25 hours unless you set an expiration, and Stripe
# retries a failed webhook for up to three days, so the key is held for four.
# The "receipt" tag lets an Inbox tab show receipts on your billing page. The
# templates format paid_at as a date, show every amount (int cents, as Stripe
# sends them) as dollars, and build the receipt link from the receipt number.
from datetime import datetime, timedelta, timezone

from courier import Courier

client = Courier()


def send_receipt(
    template_id: str,
    user_id: str,
    receipt_number: str,
    paid_at: datetime,
    payment_method: str,
    line_items: list[dict],  # [{"name": str, "amount": int}], amounts in cents
    subtotal: int,
    tax: int,
    total: int,
) -> str:
    response = client.send.message(
        message={
            "to": {"user_id": user_id},
            "template": template_id,
            "data": {
                "receipt_number": receipt_number,
                "paid_at": paid_at.isoformat(),
                "payment_method": payment_method,
                "line_items": line_items,
                "subtotal": subtotal,
                "tax": tax,
                "total": total,
            },
            "routing": {"method": "all", "channels": ["email", "inbox"]},
            "metadata": {"tags": ["receipt"]},
        },
        idempotency_key=f"receipt-{receipt_number}",
        x_idempotency_expiration=(
            datetime.now(timezone.utc) + timedelta(days=4)
        ).isoformat(),
    )
    return response.request_id
```

## Templates

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

templates/receipt.json

```json
{
  "notification": {
    "name": "Payment receipt",
    "tags": ["recipe", "payment-receipt-email"],
    "brand": null,
    "subscription": null,
    "routing": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Your Acme receipt for {{format \"$%.2f\" (divide total 100)}}"
            },
            {
              "type": "text",
              "content": "Thanks for your payment. Here's your receipt for {{receipt_number}}, paid on {{datetime-format paid_at \"MMM d, yyyy\" (default profile.timezone \"America/New_York\")}} with {{payment_method}}."
            },
            {
              "type": "text",
              "content": "{{$.item.name}}: {{format \"$%.2f\" (divide $.item.amount 100)}}",
              "loop": "data.line_items"
            },
            {
              "type": "text",
              "content": "Subtotal: {{format \"$%.2f\" (divide subtotal 100)}}"
            },
            {
              "type": "text",
              "content": "Tax: {{format \"$%.2f\" (divide tax 100)}}"
            },
            {
              "type": "text",
              "content": "Total paid: {{format \"$%.2f\" (divide total 100)}}"
            },
            {
              "type": "action",
              "content": "View receipt",
              "href": "https://app.acme.com/billing/receipts/{{receipt_number}}"
            }
          ]
        },
        {
          "type": "channel",
          "channel": "inbox",
          "elements": [
            {
              "type": "meta",
              "title": "Payment received: {{format \"$%.2f\" (divide total 100)}}"
            },
            {
              "type": "text",
              "content": "Receipt {{receipt_number}}, paid on {{datetime-format paid_at \"MMM d, yyyy\" (default profile.timezone \"America/New_York\")}} with {{payment_method}}."
            },
            {
              "type": "action",
              "content": "View receipt",
              "href": "https://app.acme.com/billing/receipts/{{receipt_number}}"
            }
          ]
        }
      ]
    }
  },
  "state": "PUBLISHED"
}
```

## Hand it to your agent

```text
Add payment receipts to this app, sent through Courier: an email with every line item and the totals, plus an Inbox copy for the billing page. Follow the steps at https://www.courier.com/recipes/payment-receipt-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_RECEIPT_TEMPLATE_ID.
2. Find where this codebase handles a successful payment (a payment webhook or the code right after a charge) and send the receipt there with the recipe's send code. Pass the fields in the Data fields table from our own data. Replace the placeholder product name (Acme) and receipt link (https://app.acme.com/billing/receipts/) in the template with ours.
3. Keep the receipt number as the idempotency key, with the four-day expiration the send code sets, so a webhook retried within four days doesn't send a second receipt.
4. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
5. When you're done, send one receipt with three line items to a test user and show me the rendered email.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| receipt_number | string | R-20481 | Also the idempotency key, held for four days, and the end of the receipt link |
| paid_at | string | 2026-10-15T17:00:00Z | An ISO timestamp. The template formats it as a date in the recipient's timezone |
| payment_method | string | Visa ending in 4242 |  |
| line_items | array | [{"name": "Pro plan (monthly)", "amount": 4900}, ...] | One object per item, amounts in cents |
| subtotal | integer | 8600 | In cents |
| tax | integer | 731 | In cents |
| total | integer | 9331 | In cents |

## FAQ

### How do I list line items in a receipt email?

Pass the items as an array in your data and add "loop": "data.line_items" to a text element. Courier repeats the element once per item, and {{$.item.name}} reads from the current item.

### Should a receipt also go to the in-app Inbox?

It's worth it if your product has a billing page: the Inbox copy stays there as a receipt history, and a tag lets a tab show only receipts. If you don't use the Inbox, send by email only.

### How do I avoid sending the same receipt twice?

Pass an idempotency key with the send, like the receipt number. If your payment webhook retries, Courier returns the first response instead of sending again. Courier keeps a key for 25 hours by default, and Stripe retries a failed webhook for up to three days, so this recipe sets the key's expiration to four days. A retry after that would send again.

### Do I need to format amounts before I send them?

Pass the integer cents your payment provider already gives you, and let the template show dollars. The template writes the $ sign, so change it for other currencies.

## Related docs

- [Elemental control flow](https://www.courier.com/docs/design/elemental/control-flow)
- [Send to the Inbox](https://www.courier.com/docs/in-app/send-to-the-inbox)
- [Routing and fallback](https://www.courier.com/docs/send/routing)
- [Send a message](https://www.courier.com/docs/send/overview)

## Related recipes

- [Failed payment recovery](https://www.courier.com/recipes/dunning-email-sequence): Recover failed payments with three reminders that stop the moment the card goes through.
- [Expiring card reminders](https://www.courier.com/recipes/expiring-card-reminder-email): Get cards updated before they expire, so a payment never fails over a date.
- [Renewal reminders](https://www.courier.com/recipes/subscription-renewal-reminder-email): Remind annual customers before they renew, so the charge never comes as a surprise.
