# Subscription receipt

A recurring subscription receipt template with the billing period, the amount, and the next charge date. Table-based HTML, a text alternative, an idempotency key, and code in TypeScript, Python, and curl.

A renewal receipt is the same charge, every month, to somebody who has stopped thinking about it. That is exactly why it must be sent: the alternative is a customer who notices the line on a statement instead and calls it fraud.

What separates this from a one-off receipt is the period. Say which dates the money bought and when the next charge lands, because a subscriber who can see the next date is a subscriber who cancels in your dashboard rather than at their bank.

Topics: subscription receipt, renewal, recurring billing, saas.

## When it sends

On each successful renewal charge, from the payment provider webhook. A failed renewal is a different email, and it should not look like this one.

## What you need

- **An emails.sh API key**: Created in the dashboard, starting with esh_. It stays on your server.
- **A sending domain**: Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.
- **The renewal event**: The invoice paid webhook, with the period it covers and the amount that settled.
- **The next charge date**: The date your billing system will act on. Naming it prevents most billing tickets.

## Subject

```text
{{PRODUCT_NAME}} subscription renewed, {{TOTAL_AMOUNT}}
```

## HTML body

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<!-- Stops iOS Mail resizing the text on its own. -->
<meta name="x-apple-disable-message-reformatting">
<title>Subscription renewed</title>
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4f4f5;">
<div style="display:none;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f4f4f5;">{{PLAN_NAME}} for {{PERIOD_START}} to {{PERIOD_END}}.&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;background-color:#ffffff;border:1px solid #e4e4e7;">
<tr><td style="padding:32px;">
<h1 style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:21px;line-height:28px;font-weight:600;color:#18181b;">Your subscription renewed</h1>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">{{TOTAL_AMOUNT}} was charged to the card ending {{CARD_LAST4}} for {{PLAN_NAME}}, covering {{PERIOD_START}} to {{PERIOD_END}}.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;border-top:1px solid #e4e4e7;"><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Invoice</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{INVOICE_NUMBER}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Plan</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{PLAN_NAME}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Period</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{PERIOD_START}} to {{PERIOD_END}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Charged</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{TOTAL_AMOUNT}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Next charge</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{NEXT_CHARGE_DATE}}</td></tr></table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#18181b" style="background-color:#18181b;"><a href="{{INVOICE_URL}}" style="display:inline-block;padding:13px 24px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;">View invoice</a></td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">Button not working? Paste this into your browser:<br><a href="{{INVOICE_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{INVOICE_URL}}</a></p>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">Change plan, update the card, or cancel before {{NEXT_CHARGE_DATE}}: <a href="{{BILLING_URL}}" style="color:#18181b;text-decoration:underline;">{{BILLING_URL}}</a>.</p>
</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;">
<tr><td style="padding:20px 32px 0;">
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">{{PRODUCT_NAME}} sends a receipt for every renewal.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
```

## Plain text body

```text
Your subscription renewed

{{TOTAL_AMOUNT}} was charged to the card ending {{CARD_LAST4}} for
{{PLAN_NAME}}, covering {{PERIOD_START}} to {{PERIOD_END}}.

Invoice:      {{INVOICE_NUMBER}}
Plan:         {{PLAN_NAME}}
Period:       {{PERIOD_START}} to {{PERIOD_END}}
Charged:      {{TOTAL_AMOUNT}}
Next charge:  {{NEXT_CHARGE_DATE}}

View invoice:
{{INVOICE_URL}}

Change plan, update the card, or cancel before {{NEXT_CHARGE_DATE}}:
{{BILLING_URL}}
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Who charged them. | Acme |
| {{PLAN_NAME}} | The plan that renewed. | Team, 5 seats |
| {{TOTAL_AMOUNT}} | What was charged. | $120.00 |
| {{PERIOD_START}} | First day the payment covers. | 1 August 2026 |
| {{PERIOD_END}} | Last day the payment covers. | 31 August 2026 |
| {{NEXT_CHARGE_DATE}} | When the next charge happens. | 1 September 2026 |
| {{CARD_LAST4}} | The card charged. | 4242 |
| {{INVOICE_NUMBER}} | Your reference for this renewal. | INV-2026-08-0031 |
| {{INVOICE_URL}} | The hosted invoice or receipt. | https://acme.com/invoices/INV-2026-08-0031 |
| {{BILLING_URL}} | Where they change plan or cancel. | https://acme.com/settings/billing |

## How it works

1. **The renewal charge settles** The invoice paid webhook arrives with the period and the amount.
2. **You send this** Keyed on the invoice number, so a retried webhook cannot duplicate it.
3. **They see the next date** Which is the number that stops a cancellation becoming a chargeback.
4. **A failure takes another path** A declined renewal gets its own email that says what to fix and by when.

## Code

### TypeScript

`npm install @emails.sh/sdk`

```
import { readFileSync } from 'node:fs';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

// What changes per send. In your app these come from the row you just
// wrote, not from literals.
const vars: Record<string, string> = {
  PRODUCT_NAME: 'Acme',
  PLAN_NAME: 'Team, 5 seats',
  TOTAL_AMOUNT: '$120.00',
  PERIOD_START: '1 August 2026',
  PERIOD_END: '31 August 2026',
  NEXT_CHARGE_DATE: '1 September 2026',
  CARD_LAST4: '4242',
  INVOICE_NUMBER: 'INV-2026-08-0031',
  INVOICE_URL: 'https://acme.com/invoices/INV-2026-08-0031',
  BILLING_URL: 'https://acme.com/settings/billing',
};

const fill = (body: string) =>
  Object.entries(vars).reduce(
    (out, [name, value]) => out.replaceAll('{{' + name + '}}', value),
    body
  );

const { id } = await mail.send({
  from: 'Acme <onboarding@emails.sh>',
  to: ['ada@example.com'],
  subject: fill('{{PRODUCT_NAME}} subscription renewed, {{TOTAL_AMOUNT}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  idempotencyKey: 'renewal-' + vars.INVOICE_NUMBER,
  tags: { type: 'subscription-receipt' }
});

// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);
```

### Python

`pip install emailssh`

```
import os

from emailssh import Emailssh

mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])

# What changes per send. In your app these come from the row you just
# wrote, not from literals.
values = {
    "PRODUCT_NAME": "Acme",
    "PLAN_NAME": "Team, 5 seats",
    "TOTAL_AMOUNT": "$120.00",
    "PERIOD_START": "1 August 2026",
    "PERIOD_END": "31 August 2026",
    "NEXT_CHARGE_DATE": "1 September 2026",
    "CARD_LAST4": "4242",
    "INVOICE_NUMBER": "INV-2026-08-0031",
    "INVOICE_URL": "https://acme.com/invoices/INV-2026-08-0031",
    "BILLING_URL": "https://acme.com/settings/billing",
}


def fill(body: str) -> str:
    for name, value in values.items():
        body = body.replace("{{" + name + "}}", value)
    return body


with open("email.html", encoding="utf-8") as f:
    html = f.read()
with open("email.txt", encoding="utf-8") as f:
    text = f.read()

sent = mail.send(
    # from_ rather than from, which is a Python keyword.
    from_="Acme <onboarding@emails.sh>",
    to=["ada@example.com"],
    subject=fill("{{PRODUCT_NAME}} subscription renewed, {{TOTAL_AMOUNT}}"),
    html=fill(html),
    text=fill(text),
    idempotency_key="renewal-" + values["INVOICE_NUMBER"],
    tags={"type": "subscription-receipt"},
)

# Queued, not delivered. Ask for the id later, or take the webhook.
print(sent["id"])
```

### curl

`Anywhere with curl and jq`

```
export EMAILSSH_API_KEY=esh_live_...

# The API substitutes nothing, so fill the placeholders before you post.
fill() {
  sed \
      -e 's|{{PRODUCT_NAME}}|Acme|g' \
      -e 's|{{PLAN_NAME}}|Team, 5 seats|g' \
      -e 's|{{TOTAL_AMOUNT}}|$120.00|g' \
      -e 's|{{PERIOD_START}}|1 August 2026|g' \
      -e 's|{{PERIOD_END}}|31 August 2026|g' \
      -e 's|{{NEXT_CHARGE_DATE}}|1 September 2026|g' \
      -e 's|{{CARD_LAST4}}|4242|g' \
      -e 's|{{INVOICE_NUMBER}}|INV-2026-08-0031|g' \
      -e 's|{{INVOICE_URL}}|https://acme.com/invoices/INV-2026-08-0031|g' \
      -e 's|{{BILLING_URL}}|https://acme.com/settings/billing|g' "$1"
}

curl -sS https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
      --arg subject 'Acme subscription renewed, $120.00' \
      --arg html "$(fill email.html)" \
      --arg text "$(fill email.txt)" \
      '{from: "Acme <onboarding@emails.sh>",
        to: ["ada@example.com"],
        subject: $subject,
        html: $html,
        text: $text,
        idempotency_key: "renewal-INV-2026-08-0031",
        tags: {type: "subscription-receipt"}}')"
```

## Questions

### Should a failed renewal use this template?

No. A dunning email has a different job: it says what failed, what will be retried, when access stops, and where to fix the card. Reusing the receipt for it reads as though the money went through.

### Monthly receipts feel like clutter. Can we skip them?

Skipping them moves the discovery of the charge to a bank statement, which is the most expensive place a customer can find it. It is also the sort of thing card networks look at when a dispute rate climbs.

### Where do tax details go?

On the invoice document behind the link, which is where a finance team looks anyway. Keep the email to the summary and let the hosted copy carry the full breakdown.

Docs: https://emails.sh/docs.md
All templates: https://emails.sh/templates.md