Account and security

Payment receipt email: template and code

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

Passing, tested 2026-10-19 · Email, Inbox

How it works

When to send a payment receipt email

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.

Messages

What it sends

When a payment succeeds

Email and Inbox.

Email

From: Acme
Your Acme receipt for $93.31

Thanks for your payment. Here's your receipt for R-20481, paid on Oct 15, 2026 with Visa ending in 4242.

Pro plan (monthly): $49.00

2 extra seats: $30.00

SMS add-on: $7.00

Subtotal: $86.00

Tax: $7.31

Total paid: $93.31

View receipt

In-app Inbox

Payment received: $93.31

Receipt R-20481, paid on Oct 15, 2026 with Visa ending in 4242.

View receipt

Other subject lines: "Thanks for your payment to Acme", "Receipt R-20481 from Acme"

Build

Add it to your app

1. Create the template

Run setup.ts once with your API key. It creates the template in your workspace, publishes it, and prints the template ID you pass to step 2. Copy setup.ts and the templates folder from the full definition at the end of this section.

Terminal

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

2. Send it when a payment succeeds

Node.js

// 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
Full definition: setup.ts, and the 1 template

One template with an email version and an Inbox version. The email's line items come from the line_items array in your data: one looped text element repeats once per item. Amounts are integer cents, as Stripe sends them.

The Inbox copy is tagged receipt, so a tab can show receipts on your billing page. Dates show in each recipient's timezone from their profile, and New York when it isn't set.

setup.ts

// 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 }));

templates/receipt.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"
}

Agents

Hand it to your agent

Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.

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

Data fields

FieldTypeExampleNote
receipt_numberstringR-20481Also the idempotency key, held for four days, and the end of the receipt link
paid_atstring2026-10-15T17:00:00ZAn ISO timestamp. The template formats it as a date in the recipient's timezone
payment_methodstringVisa ending in 4242
line_itemsarray[{"name": "Pro plan (monthly)", "amount": 4900}, ...]One object per item, amounts in cents
subtotalinteger8600In cents
taxinteger731In cents
totalinteger9331In cents

FAQ

Frequently asked questions

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.

Build this in your workspace

Run setup.ts against a free account and the template is ready to send. Prefer no code? Rebuild it in Design Studio.