Subscriptions
A subscription charges a card the customer already saved, on a plan’s calendar. Each charge is an ordinary off-session charge, so mandates, receipts and 3-D Secure recovery all apply. A subscription only decides when.
- Plan
POST /v1/plans - Save a card
save_card: "off_session" - Subscribe
POST /v1/subscriptions - Renewsan invoice each period
1. Create a plan
Section titled “1. Create a plan”A plan is what you sell on repeat: a name, an amount, an interval (day, week, month,
year), optionally interval_count (3 = quarterly) and trial_days.
A plan’s price never changes. A new price is a new plan, so a subscription always shows what it agreed to. An update can only change the name or metadata. Archiving stops new subscriptions; existing ones carry on.
curl -sS https://api.tuppence.ai/v1/plans \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "Box of the month", "amount": 2500, "interval": "month"}'import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const monthly = await tuppence.plans.create({ name: "Box of the month", amount: 2500, interval: "month" });console.log(monthly.amount, monthly.interval, monthly.status); // 2500 month active
// Renaming is fine. The price is not a field you can send.const renamed = await tuppence.plans.update(monthly.id, { name: "Box of the month (large)" });console.log(renamed.name, renamed.amount); // Box of the month (large) 2500
const old = await tuppence.plans.create({ name: "Retired tier", amount: 900, interval: "month" });await tuppence.plans.archive(old.id);import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
monthly = tuppence.plans.create(name="Box of the month", amount=2500, interval="month")yearly = tuppence.plans.create(name="Box of the year", amount=25000, interval="year")
print(monthly.amount, monthly.interval) # 2500 monthprint(yearly.status) # active
# Renaming is fine. The price is not a field you can send.renamed = tuppence.plans.update(monthly.id, name="Box of the month (large)")print(renamed.name, renamed.amount) # Box of the month (large) 2500
old = tuppence.plans.create(name="Retired tier", amount=900, interval="month")tuppence.plans.archive(old.id)print([p.name for p in tuppence.plans.list(status="active").data][:2])2. Start a subscription
Section titled “2. Start a subscription”First save the customer’s card: take one payment with
customer and save_card: "off_session". The payer agrees to the terms when they pay it.
Or let a link do both.
The first period is charged at once unless the plan has a trial. The subscription charges the
customer’s most recent saved card; name another with payment_method.
curl -sS https://api.tuppence.ai/v1/subscriptions \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer": "cus_…", "plan": "plan_…"}'const subscription = await tuppence.subscriptions.create({ customer: customer.id, plan: monthly.id });console.log(subscription.status, subscription.current_period_end); // active …subscription = tuppence.subscriptions.create(customer=customer.id, plan=monthly.id)print(subscription.status, subscription.current_period_end) # active …Statuses
Section titled “Statuses”| Event | When |
|---|---|
subscription.created | It exists |
subscription.started | The first period is paid |
subscription.renewed | A period was charged |
subscription.updated | The plan changed, or a change is pending |
subscription.payment_failed | An attempt failed; attempts says how many, failure_code why (null when unknown) |
subscription.recovered | They paid with another card |
subscription.canceled | Ended: by you, or by the schedule running out |
subscription.paused | Paused; resumes_at says until when, or null for until resumed |
subscription.resumed | Running again |
subscription.trial_will_end | Three days before a trial becomes a charge; trial_end says when |
invoice.created, invoice.paid, invoice.payment_failed | Each invoice |
Changing plan
Section titled “Changing plan”The period never moves. A customer who changes twice in a month isn’t billed three times and still renews on the same day.
previewprices the change: unused time on the old plan is credited, remaining time on the new one charged. It returns the instant it priced at,proration_at.- Pass
proration_attochangeand the charge matches the preview, to the penny.
curl -sS https://api.tuppence.ai/v1/subscriptions/sub_…/preview \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"plan": "plan_…"}'
curl -sS https://api.tuppence.ai/v1/subscriptions/sub_…/change \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"plan": "plan_…", "proration_at": 1798761600}'const quote = await tuppence.subscriptions.preview(subscription.id, { plan: yearly.id });for (const line of quote.lines) console.log(line.description, line.amount);console.log(quote.amount, quote.at_period_end);
await tuppence.subscriptions.change(subscription.id, { plan: yearly.id, proration_at: quote.proration_at });quote = tuppence.subscriptions.preview(subscription.id, plan=yearly.id)for line in quote.lines: print(line.description, line.amount)print(quote.amount, quote.at_period_end)
tuppence.subscriptions.change(subscription.id, plan=yearly.id, proration_at=quote.proration_at)| Change | What happens |
|---|---|
| Upgrade | Charged for the time remaining; takes effect once paid. at_period_end defaults to false |
| Downgrade | Waits for the period already paid for. at_period_end defaults to true |
| Downgrade, now | Unused time becomes credit against the next renewal, not a refund. Credit larger than a renewal buys a whole period with no card touched |
When a card stops working
Section titled “When a card stops working”A failed renewal cancels nothing. The subscription goes past_due and we retry after 1, 3, 7
and 14 days, emailing the payer each time with the amount owed, the next try, and a link to pay
with another card. Paying through the link collects the money and replaces the saved card.
After the last failed try the subscription ends and the payer is told.
Paid by Direct Debit
Section titled “Paid by Direct Debit”If your UK business takes Direct Debit, a subscription can be paid from a saved bank account (Bacs, in pounds). A subscription link offers it beside cards. The first debit saves the account, and the subscription starts when that debit succeeds. A debit takes about three working days:
| Case | What happens |
|---|---|
| A renewal | Its invoice is processing. The period moves on, once, when the debit succeeds and the invoice is paid |
| A debit fails | The invoice opens again and the retries start. Each retry is a new debit with a new advance notice. Only insufficient_funds and generic_could_not_process are retried. For other failures the payer is asked to pay another way by the end of the window |
| The Direct Debit is cancelled | Nothing more is debited. At the next renewal the invoice stays open, the subscription is past_due, the payer is emailed a link to pay (by card or a new Direct Debit), and you get subscription.payment_failed with failure_code: "payment_method_unusable" and an alert. Paying that link saves the new way to pay |
| Money lands after it ended | The subscription stays ended. The invoice is marked paid and you get an alert to refund or restart. Only a payment begun after it ended, through the last email’s link, restarts it |
Ending one
Section titled “Ending one”curl -sS https://api.tuppence.ai/v1/subscriptions/sub_…/cancel \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"at_period_end": true}'await tuppence.subscriptions.cancel(subscription.id, { at_period_end: true }); // at the period endawait tuppence.subscriptions.cancel(subscription.id); // nowawait tuppence.subscriptions.reactivate(subscription.id); // take back a cancellation at the period endtuppence.subscriptions.cancel(subscription.id, at_period_end=True) # at the period endtuppence.subscriptions.cancel(subscription.id) # nowtuppence.subscriptions.reactivate(subscription.id) # take back a cancellation at the period endCancelling at the period end is usually kinder: the customer keeps what they paid for and isn’t
charged again. Nothing already paid is refunded either way. Call reactivate before the period
ends to make it renew as before.
Pausing one
Section titled “Pausing one”Pausing stops the next charge; the paid period runs to its end. Pause until a date
(resumes_at) or until you resume.
curl -sS https://api.tuppence.ai/v1/subscriptions/sub_…/pause \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" -d '{"resumes_at": 1798761600}'await tuppence.subscriptions.pause(subscription.id, { resumes_at: 1_798_761_600 });await tuppence.subscriptions.pause(subscription.id); // until resumedawait tuppence.subscriptions.resume(subscription.id);tuppence.subscriptions.pause(subscription.id, resumes_at=1_798_761_600)tuppence.subscriptions.pause(subscription.id) # until resumedtuppence.subscriptions.resume(subscription.id)| Resumed | What happens |
|---|---|
| Inside the paid period | It carries on and renews when that period ends |
| After it | A new period starts that day and is charged then; a card that stopped working meanwhile goes through the usual retries |
| Can’t pause | A trial (it’s free already), or a subscription that owes a payment |
Coupons and promo codes
Section titled “Coupons and promo codes”A coupon takes a percentage (percent_off) or an amount (amount_off, with currency) off a subscription’s charges.
Its duration is once, repeating (with duration_in_periods) or forever. Its terms never change.
A promo code is a name a customer types for a coupon, matched case-insensitively.
curl -sS https://api.tuppence.ai/v1/coupons \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"percent_off": 50, "duration": "repeating", "duration_in_periods": 3}'
curl -sS https://api.tuppence.ai/v1/promo_codes \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"coupon": "cpn_…", "code": "SPRING", "max_redemptions": 100, "first_time_only": true}'const half = await tuppence.request("POST", "/v1/coupons", { body: { percent_off: 50, duration: "repeating", duration_in_periods: 3 },});await tuppence.subscriptions.create({ customer: customer.id, plan: monthly.id, coupon: half.id });
await tuppence.request("POST", "/v1/promo_codes", { body: { coupon: half.id, code: "SPRING", max_redemptions: 100, first_time_only: true },});await tuppence.subscriptions.create({ customer: customer.id, plan: monthly.id, promo_code: "spring" });half = tuppence.coupons.create(percent_off=50, duration="repeating", duration_in_periods=3)tuppence.subscriptions.create(customer=customer.id, plan=monthly.id, coupon=half.id)
tuppence.promo_codes.create(coupon=half.id, code="SPRING", max_redemptions=100, first_time_only=True)tuppence.subscriptions.create(customer=customer.id, plan=monthly.id, promo_code="spring")| Rule | |
|---|---|
| Rounding | A percentage rounds down, so a coupon never gives more than it says. If a discount leaves less than a card will take, that charge is free |
| Limits | max_redemptions (on a coupon and a code), redeem_by and expires_at are enforced when the subscription is created, so the last redemption goes to exactly one customer |
first_time_only | Only for a customer who has never subscribed on your account |
| Stopping | Archive a coupon or deactivate a code to stop new redemptions. Subscriptions that already have it keep it |
Invoices
Section titled “Invoices”Every charge has an invoice, opened before the card is charged: one per period (and per mid-period change), however many retries. Invoices are numbered 1, 2, 3… per account without gaps. The lines (price, coupon, credit) add up to exactly what the card was asked for.
curl -sS "https://api.tuppence.ai/v1/invoices?subscription=sub_…" \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY"const invoices = await tuppence.request("GET", "/v1/invoices", { query: { subscription: subscription.id } });for (const invoice of invoices.data) { console.log(invoice.number_label, invoice.status, invoice.total, invoice.hosted_invoice_url);}for invoice in tuppence.invoices.list(subscription=subscription.id).data: print(invoice.number_label, invoice.status, invoice.total, invoice.hosted_invoice_url)status | Means |
|---|---|
open | Owed, and being retried |
processing | A Direct Debit for it is on its way |
paid | A payment settled it |
uncollectible | The retries ran out |
GET /v1/invoices/{id}/pdf returns the PDF; hosted_invoice_url is the payer’s copy behind an
unguessable link. We do not calculate tax. An invoice shows the price and what came off it.
Include VAT or sales tax in your prices and your own records.
The customer portal
Section titled “The customer portal”To let payers manage their own subscription, create a portal session for one customer and send
them to its url. It works for an hour, for that customer only.
curl -sS https://api.tuppence.ai/v1/portal_sessions \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer": "cus_…", "return_url": "https://example.com/account", "allowed_actions": ["cancel", "update_card", "invoices"]}'const session = await tuppence.request("POST", "/v1/portal_sessions", { body: { customer: customer.id, return_url: "https://example.com/account", allowed_actions: ["cancel", "update_card", "invoices"], // the default is all four },});res.redirect(session.url);session = tuppence.portal_sessions.create( customer=customer.id, return_url="https://example.com/account", allowed_actions=["cancel", "update_card", "invoices"], # the default is all four)redirect(session.url)The payer sees their subscriptions and invoices and can do what you allowed:
- cancel at the end of the paid period (and undo it)
- change to one of the
plansyou offer, at the previewed price - add a new card (charged at once if a payment is behind)
- download invoices
Each subscription shows how it’s paid: card brand and last digits, or Bacs Direct Debit with the account’s last digits and mandate reference (and, if cancelled, an offer to update). A new way to pay added in the portal is always a card. Create a new session each time a signed-in customer asks.
When a trial ends
Section titled “When a trial ends”Three days before, the payer is emailed the amount and date of the charge, and you get
subscription.trial_will_end. When the trial ends, the first period is charged and starts that
day. It renews on that day of the month from then on.
Testing a year in a second
Section titled “Testing a year in a second”Subscriptions renew when you advance a test clock:
curl -sS https://api.tuppence.ai/v1/test_helpers/clock/advance \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" -d '{"days": 32}'await tuppence.request("POST", "/v1/test_helpers/clock/advance", { body: { days: 32 } });console.log((await tuppence.subscriptions.retrieve(subscription.id)).current_period_start);tuppence.request("POST", "/v1/test_helpers/clock/advance", body={"days": 32})print(tuppence.subscriptions.retrieve(subscription.id).current_period_start)See Testing for more on test clocks.
Subscribing from a link
Section titled “Subscribing from a link”A payment link with mode: "subscription" starts a subscription when
paid: the payer enters an email, pays the first period, and their card is saved. No account or
password. The plan sets the price, and we keep the recurring terms on the page as their mandate.
curl -sS https://api.tuppence.ai/v1/payment_links \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "Join the coffee club", "mode": "subscription", "plan": "plan_…"}'import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const plan = await tuppence.plans.create({ name: "Coffee club", amount: 1200, interval: "month" });const link = await tuppence.paymentLinks.create({ name: "Join the coffee club", mode: "subscription", plan: plan.id });console.log(link.mode, link.url);import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
plan = tuppence.plans.create(name="Coffee club", amount=1200, interval="month")link = tuppence.payment_links.create(name="Join the coffee club", mode="subscription", plan=plan.id)print(link.mode, link.url)A plan with a free trial can’t be sold from a link yet, because the card is saved by paying the
first period. Start those with subscriptions.create.
What we do not do
Section titled “What we do not do”- No usage-based billing.
- No invoices on their own: every invoice belongs to a subscription’s charge.
- The portal changes the way to pay only to a card (a Direct Debit is set up by paying).
- No tax calculation anywhere: see Not supported.
- Webhooks: hear renewals and failures.
- Direct Debit: subscriptions paid from a bank account.