Subscriptions: a membership or season pass sold on the web, renewed by card

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:

FieldValues
plan_idyour key, [a-z0-9-]{2,40}, stable once published
nameshown on the card and the receipt
periodmonth or year
pricesone 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_days0 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
statedraft, 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. Acknowledge with 2xx; dedupe on idempotency-key.

{
  "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
  }
}
EventWhenWhat your backend does
subscription.startedfirst charge paid, or trial began (cycle 1; price 0 during a trial)grant the benefits until period_end
subscription.reneweda renewal charge paidextend the benefits to the new period_end
subscription.payment_faileda renewal charge failed; grace_until is setkeep the benefits until grace_until; optionally tell the player in-game
subscription.canceledthe player canceled; cancel_at_period_end is truenothing yet; benefits run to period_end
subscription.expiredperiod_end or grace_until passed without a paid cyclerevoke the benefits
subscription.refundeda cycle was refunded from Studio; refunded_cycle is setyour 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

Access is tied to the player_id in the player token. 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

Test mode

Plans and subscriptions work in test mode with the test card from the 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)

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);
});