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.
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
In-app Inbox
Receipt R-20481, paid on Oct 15, 2026 with Visa ending in 4242.
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, timezonefrom courier import Courierclient = 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 centssubtotal: 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.
Data
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
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.
Recipes
Related recipes
Recover failed payments with three reminders that stop the moment the card goes through.
Get cards updated before they expire, so a payment never fails over a date.
Remind annual customers before they renew, so the charge never comes as a surprise.
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.