# Building a subscription system: the parts nobody warns you about

URL: https://www.paydude.io/resources/building-a-subscription-system
Type: Guide
Published: 2026-09-24 · Updated: 2026-09-24
Summary: Billing on a schedule is the easy part. The timezone trap in your cycle anchor, the subscription states you will forget, and how to handle failed payments.

## Summary

The recurring charge is the easy part; timing, state and failed payments are where the work is. A date-only billing cycle anchor is read as midnight UTC and bills North American customers a day early. Subscriptions take a price field in minor units — payment intents take amount for the same idea. A pending status is the healthy result of creating a subscription, and does not need confirming. Separate billing state from entitlement state, and drive entitlement from webhooks. Dunning — retrying and chasing failed payments — recovers more revenue than most pricing changes.

Charging a card every month is a solved problem and your processor solves it. What is not solved for you is everything around it: when exactly the charge lands, what your product does between a failed payment and a cancellation, and which of the eight states a subscription can be in should still let someone log in.

This is about that second category.

## Creating the subscription

The prerequisite is a customer with an **attached** payment method — a freshly tokenized card is single-use and expires in about fifteen minutes, so it cannot back a subscription. That sequence is covered in [storing payment methods safely](https://www.paydude.io/resources/storing-payment-methods-safely).

```http
POST /v1/subscriptions
{
  "customer_id":          "cus_yP8jSLsFD16WmiH1mW6rl",
  "payment_method_id":    "pm_7qOHcBHz0zBtIYrS37sOT",
  "currency":             "usd",
  "price":                2500,
  "interval_unit":        "month",
  "interval_count":       1,
  "billing_cycle_anchor": "2026-10-01T09:00:00-04:00"
}
-> 201 { "id": "sub_dDRoYZIbPAFvTmRgmfxym",
         "status": "pending",
         "next_payment_at": "2026-10-01T13:00:00.000Z" }
```

> **price, not amount:** Subscriptions use price where payment intents use amount, for the same concept, both in minor units. Sending amount returns a validation error naming price — it tells you about the field you omitted rather than the one you sent, so it reads like a number-formatting problem. It is a wrong field name.

## The timezone trap in the cycle anchor

This one is worth its own section because it is silent, it affects every customer, and it recurs every cycle rather than once.

A date-only anchor like `"2026-10-01"` is interpreted as midnight UTC. Midnight UTC on 1 October is **8PM Eastern on 30 September**. Your customer is charged the day before the date on their invoice, every month, and the only signal is occasional confusion in support.

**What an anchor actually means**

| You send | It means (UTC) | Eastern time | Result |
| --- | --- | --- | --- |
| 2026-10-01 | 2026-10-01 00:00Z | 30 Sep, 8PM | Charged a day early, every cycle |
| 2026-10-01T00:00:00-04:00 | 2026-10-01 04:00Z | 1 Oct, midnight | Correct, customer-local midnight |
| 2026-10-01T09:00:00-04:00 | 2026-10-01 13:00Z | 1 Oct, 9AM | Correct, and during business hours |

> **Prefer a business-hours anchor:** Charging at 9AM local rather than midnight is a small, free improvement: a decline that needs the customer to act is more likely to be resolved the same day, and a card that was about to be topped up probably has been. Midnight is the worst moment to discover a payment problem.

## The states you will forget

Most implementations start with two states — active and cancelled — and grow the rest painfully, one production incident at a time. The full set is small enough to write down at the start.

**Subscription states and what your product should do**

| State | Means | Access? |
| --- | --- | --- |
| pending | Created, waiting for its first billing date | Depends on whether you offer a trial |
| trialing | In a free trial, no payment taken yet | Yes |
| active | Paid and current | Yes |
| past_due | A payment failed, retries still in progress | Yes — this is the grace period |
| unpaid | Retries exhausted, still not paid | No, but keep the data |
| canceled | Ended, by the customer or by you | No, at period end |
| paused | Deliberately suspended, intended to resume | No, and do not bill |
| incomplete | Created but never successfully set up | No |

The row that matters most is `past_due`. Treating it as cancelled is the single most expensive mistake in this list: it takes a customer whose card expired and turns them into a churned account, usually while they are still happily using the product and entirely willing to pay.

> **pending is healthy — do not confirm it:** A new subscription comes back with status pending, which is the expected result and simply means it is waiting for its first billing date. It is tempting to call /confirm on it. Confirmation only applies to a subscription in requires_confirmation, and calling it on a pending one returns an error saying exactly that.

## Keep billing state and entitlement state apart

There are two questions here that look like one: *has this customer paid?* and *what is this customer allowed to do?* The processor owns the first. You own the second, and you should own it in your own database.

```javascript
// Driven by webhooks, so your product never calls the payments API on a
// request path and never goes down because someone else does.
const ACCESS = {
  pending:    false,
  trialing:   true,
  active:     true,
  past_due:   true,    // the grace period — the expensive row to get wrong
  unpaid:     false,
  canceled:   false,
  paused:     false,
  incomplete: false,
};

export async function canAccess(userId) {
  const sub = await db.subscriptions.findByUser(userId);
  if (!sub) return false;

  // Trust the mirrored state, but never past its own period end: a missed
  // cancellation webhook should fail closed rather than grant forever.
  if (sub.currentPeriodEnd && sub.currentPeriodEnd < new Date()) {
    return sub.status === "past_due";   // still retrying, still inside grace
  }
  return ACCESS[sub.status] ?? false;
}
```

The period-end check is the part worth keeping. Mirrored state is only as fresh as the last webhook you successfully processed, and the failure mode of a missed cancellation is granting access indefinitely. Expiring on the stored period end makes the mirror fail closed.

## Failed payments are most of the work

A meaningful share of subscription cancellations are not decisions. The card expired, or was reissued after fraud, or the balance was short on the day. The customer wants the product; the payment stopped working. Recovering those is called **dunning**, and it usually returns more revenue than any pricing change you could ship in the same week.

1. **Space the retries out.** Three or four attempts over one to two weeks. The commonest cause is a temporary balance problem, so calendar spacing recovers far more than rapid retries.
2. **Tell the customer, in plain terms.** One clear email naming the card and the amount, with a direct link to update it. Not a dunning sequence of five.
3. **Keep access during the grace period.** Someone who can still use the product has a reason to fix the card.
4. **Distinguish the decline reasons.** An expired card needs a new card; insufficient funds needs a later retry. Sending the same message to both wastes half of them.
5. **Stop at a clear point.** Retrying indefinitely annoys people and can attract issuer scrutiny. Move to `unpaid`, revoke access, and keep the data so returning is one click.

> **The cheapest improvement in the whole system:** Warn before the card expires. You already store the expiry month and year when you store the payment method, so a note a fortnight ahead costs one scheduled job and prevents a failure that would otherwise need the entire dunning sequence to recover.

## Retries have no idempotency net

The API has no idempotency-key header, so a retried `POST /v1/subscriptions` after a timeout creates a **second subscription** and bills the customer twice. For a one-off charge that is a refund and an apology; for a subscription it is a recurring double charge that continues until somebody notices.

Record your own reference before the call and check it before retrying — the pattern is set out in [idempotency keys explained](https://www.paydude.io/resources/idempotency-keys-explained). Of everywhere that guard matters, subscription creation is the place it matters most.

## A build order that works

1. Store a card and attach it properly. Everything else depends on this.
2. Create a subscription with a full timestamp anchor, and check the first charge lands on the day you expected in the customer timezone.
3. Mirror state from webhooks into your own table, with the period-end guard.
4. Write the entitlement function, and test `past_due` grants access.
5. Add the retry guard around creation.
6. Build dunning: expiry warnings first, then failed-payment recovery.
7. Only then worry about proration, plan changes and upgrades.

Proration is last deliberately. It is the part that feels hardest because it involves arithmetic, and it is the part that costs least when it is briefly wrong — a few cents on an invoice, against an entitlement bug that either gives the product away or locks out paying customers.

**Subscriptions with no extra percentage** Recurring billing and invoices at the same published card rate. — [View pricing](https://www.paydude.io/pricing)

## Frequently asked questions

### Should I build subscription billing myself or use the processor?

Use the processor for the billing loop — the scheduled charge, the retry schedule, the proration maths. Build your own layer for entitlements: what the customer can actually do in your product. The two answer different questions, and conflating them is what makes billing code hard to change later.

### Why does my subscription bill a day early?

Almost always a date-only billing cycle anchor. A value like 2026-10-01 is interpreted as midnight UTC, which is the evening of 30 September in North American timezones. Send a full ISO 8601 timestamp with an offset and the problem disappears.

### What does a pending subscription status mean?

On the Paydude API, pending is the healthy result of creating a subscription — it means the subscription exists and is waiting for its first billing date. It does not need confirming. Only a subscription in requires_confirmation should be confirmed, and calling confirm on a pending one returns an error saying so.

### How many times should I retry a failed subscription payment?

Three or four attempts spread over one to two weeks recovers most recoverable failures without irritating anyone. The spacing matters more than the count, because the most common cause is a temporary balance problem that resolves on payday. Retrying four times in an hour recovers almost nothing.

### Should a failed payment immediately revoke access?

No. A first failed payment is usually a card problem, not a decision to leave. Keep access during a grace period while you retry and contact the customer; revoking on the first decline converts a fixable billing event into a cancellation.

### Do I need to store subscription state in my own database?

Yes, at least the parts your product reads. Checking entitlement by calling the payments API on every request couples your product availability to theirs and is slow. Mirror the state you need via webhooks, and treat the processor as the source of truth for money and your own store as the cache for access.
