---
title: Subscriptions
nav: Subscriptions
order: 11
---
# Subscriptions: a membership or season pass sold on the web, renewed by card

> **Available.** Memberships run on any store that publishes a plan in Studio → Catalogue → Plans. Cards, prices in USD, EUR and GBP. Free trials and resuming a cancelled membership are not in this version; see Limits.

A subscription is a plan the player buys in your web store and that renews automatically: a monthly VIP membership, a season pass, a "shard stipend" that drops currency every month. Appside charges the card each cycle and tells your backend what happened. **Your backend owns the entitlement**: it grants on `subscription.started`, keeps granting on `subscription.renewed`, and revokes on `subscription.expired`. The store never changes a wallet or a flag itself.

## Plans

A plan is created in Studio → Catalogue → **Plans**:

| Field | Values |
|---|---|
| `plan_id` | your key, `[a-z0-9-]{2,40}`, stable once published |
| `name` | shown on the card and the receipt |
| `period` | `month` or `year` |
| `prices` | one price per currency: USD, EUR and GBP in v1; the player pays in the currency the store shows them, and the subscription keeps that currency |
| `trial_days` | `0` to `30`; the card is stored at sign-up and first charged when the trial ends |
| `benefits[]` | up to 6 lines shown on the card; text only, the game decides what they mean |
| `state` | `draft`, `live`, `paused` (no new subscribers), `retired` (existing run to period end) |

A store can have several plans. Switching between plans (upgrade, downgrade, proration) is not in v1: a player cancels one and subscribes to another.

## Events to your Fulfillment URL

Every change is delivered to the same **Fulfillment URL** as orders, as a signed HTTPS POST with the same headers, signature, retries and dedupe rules as [`order.paid`](/docs/webhooks/). Acknowledge with 2xx; dedupe on `idempotency-key`.

```json
{
  "type": "subscription.renewed",
  "event_id": "SUB-9a2f…:3",
  "mode": "test",
  "tenant": "orbit",
  "player_id": "player-123",
  "probe": false,
  "subscription": {
    "id": "SUB-9a2f…",
    "plan_id": "star-pass",
    "status": "active",
    "cycle": 3,
    "period_start": "2026-01-15T10:00:00Z",
    "period_end": "2026-02-15T10:00:00Z",
    "price": 499,
    "currency": "USD",
    "cancel_at_period_end": false
  }
}
```

| Event | When | What your backend does |
|---|---|---|
| `subscription.started` | first charge paid, or trial began (`cycle` 1; `price` 0 during a trial) | grant the benefits until `period_end` |
| `subscription.renewed` | a renewal charge paid | extend the benefits to the new `period_end` |
| `subscription.payment_failed` | a renewal charge failed; `grace_until` is set | keep the benefits until `grace_until`; optionally tell the player in-game |
| `subscription.canceled` | the player canceled; `cancel_at_period_end` is `true` | nothing yet; benefits run to `period_end` |
| `subscription.expired` | `period_end` or `grace_until` passed without a paid cycle | revoke the benefits |
| `subscription.refunded` | a cycle was refunded from Studio; `refunded_cycle` is set | your decision: usually revoke for that cycle |

`event_id` is `<subscription_id>:<cycle>` for `started` and `renewed`, and `<subscription_id>:<cycle>:<event>` for the others, so each is granted or revoked exactly once. Events for one subscription arrive in order; if your backend is down, retries keep the order. `probe: true` is the Studio **Send test event**: acknowledge it, grant nothing.

`price` is in minor units (cents) like `order.price`. `status` is one of `trialing`, `active`, `past_due`, `canceled`, `expired`.

## What the player sees

- A **Membership** card in the store with the plan, the price per period, the benefits and, if any, the trial. Checkout is the same checkout as a pack: card, saved for renewals, with the renewal terms shown before the player confirms.
- In the player area: the plan, the next charge date and amount, the payment history, and **Cancel**, one tap, effective at period end, no questions asked. Cancelling is final for that subscription: the membership runs to the paid date, and the player can subscribe again afterwards.
- After a failed renewal: a "payment failed, update your card" notice with a retry; no benefits are removed until `grace_until`.

Access is tied to the `player_id` in the [player token](/docs/player-tokens/). If the same player also holds an in-app subscription, that is your union to compute; the store knows only about the web plan.

## Limits and rules

- Cards only in v1. Direct Debit, wallets and other methods come later.
- Plan prices in USD, EUR and GBP in v1; a subscription keeps the currency it started in. Other currencies later.
- Trials: a plan can be drafted with `trial_days`, but publishing a live plan with a trial is blocked in this version.
- Resume: not available before the paid period ends; the player subscribes again afterwards (a new subscription).
- Grace after a failed renewal: 7 days with retries on days 1, 3 and 5; then `subscription.expired`.
- Refunds are per cycle from Studio and send `subscription.refunded`.
- Taxes and the merchant of record are yours, as everywhere in Appside.
- Rate: the published per-payment rate, applied to each paid cycle. Nothing per plan or per subscriber.

## Test mode

Plans and subscriptions work in test mode with the test card from the [testing](/docs/testing/) page (Google Pay in the sandbox works too). Renewals follow the real monthly or annual cycle; `payment_failed` and `expired` are produced by the provider's retry cycle, so plan a full cycle for an end-to-end test of those.

## Example (Node)

```js
app.post('/appside/fulfillment', verifyAppside, async (req, res) => {
  const ev = req.body;
  if (ev.probe) return res.sendStatus(200);
  if (await seen(ev.event_id)) return res.sendStatus(200);
  const s = ev.subscription;
  switch (ev.type) {
    case 'subscription.started':
    case 'subscription.renewed':
      await grantPass(ev.player_id, s.plan_id, new Date(s.period_end)); break;
    case 'subscription.payment_failed':
      await grantPass(ev.player_id, s.plan_id, new Date(s.grace_until)); break;
    case 'subscription.expired':
    case 'subscription.refunded':
      await revokePass(ev.player_id, s.plan_id); break;
    case 'subscription.canceled':
      break; // runs to period_end; nothing to do yet
    default:
      return handleOrderEvent(ev, res);
  }
  await markSeen(ev.event_id);
  res.sendStatus(200);
});
```
