Subscriptions
Subscriptions are in beta. The API, payload shapes, and Developer Console UI may change without notice. Contact the Jest team before shipping a subscription to production, and keep an eye on What's new for breaking changes.
The Jest platform allows developers to sell recurring subscriptions to their games using the payments SDK methods. Subscriptions complement one-off product purchases and are a good fit for things like ad removal, premium tiers, or access to additional content.
As with one-off purchases, Jest handles the entire checkout and recurring billing with the player via popular digital wallets (Apple Pay, Google Wallet, card on file). Your game is responsible for reading the player's current entitlement and unlocking the relevant features.

How subscriptions differ from products
Unlike one-off product purchases, subscriptions:
- Are tied to the player's wallet, not a single transaction. The wallet is what's billed on the recurring schedule.
- Don't require a
completestep. The platform manages the billing lifecycle; your game only needs to read the wallet's current entitlement on startup (and afterbeginSubscriptionsucceeds) and unlock features accordingly. - Are only available to registered users. Guests must register before they can subscribe.
- Are returned alongside their current
status(activeorinactive), so the same response tells your game both what's on offer and what the player already has.
Subscription lifecycle
A subscription represents an ongoing entitlement granted to a wallet for as long as billing succeeds.
- On startup, call
getSubscriptions()to read the catalog and the wallet's current entitlement. - Unlock subscription-gated features based on each subscription's
status. - If the player chooses to subscribe to an inactive offering, call
beginSubscription({ subscriptionSku }). - If checkout succeeds, the SDK returns the now-active subscription. Apply the entitlement in your game.
- Cancellations, expirations, and renewals are handled by the platform; the next call to
getSubscriptions()will reflect the updatedstatus.
Every subscription SKU is an independent product — the platform does not relate SKUs that unlock the same thing (e.g. a monthly and a yearly cadence of one tier). Once a player has an active subscription, your game must not offer other subscriptions that grant the same entitlements: hide or disable those offers, or the player could end up paying for both.
Happy path (new subscriber)
Returning subscriber (startup reconciliation)
On every startup, your game should re-read getSubscriptions() and apply the resulting entitlements. This is how you reflect cancellations, expiries, and renewals that happened while the player was away.
Free trials
A subscription can offer a free trial, configured per-product in the Developer Console (see Manage subscriptions). Trials are handled entirely by the platform — you don't run the trial yourself.
What this means for your game:
- During the trial the subscription's
statusis"active", exactly like a paid subscription. Readstatusand unlock features as usual; don't special-case trials. - A trial is granted only to wallets that have never subscribed to that product before. Returning subscribers are billed immediately.
beginSubscriptionapplies the trial automatically when the player is eligible. - At the end of the trial the platform charges the player and recurring billing continues. If the player cancels during the trial, the entitlement stays
"active"until the trial ends, after which the nextgetSubscriptions()reflects"inactive".
trialEligible — showing the right call to action
SubscriptionData includes a trialEligible flag so your subscribe CTA can promise a trial only when the player will actually get one. It is true when the product has a trial configured and the wallet has never subscribed to it before; otherwise it is false (no trial configured, the player already used it, or they are currently entitled).
Use it to pick the copy on an "inactive" offer — for example "Start free trial" when trialEligible is true, versus "Reactivate" or "Subscribe for $9.99/mo" when it is false. It does not change what beginSubscription does; the platform still applies the trial only to eligible wallets. Treat trialEligible as display-only and don't gate entitlement on it.
Introductory offers
A subscription can offer an introductory price — a discounted price for the first N months, configured per-product in the Developer Console (see Manage subscriptions). Like trials, intro offers are handled entirely by the platform: eligible players pay the discounted price for the configured months and then transition to the standard price automatically.
What this means for your game:
SubscriptionDatacarries the offer as a single nullableintroOfferfield: the discountedpriceand thedurationPeriods(billing periods) it applies for.introOfferis non-null only when an intro offer is configured and the wallet has never subscribed to that product before — the same rule astrialEligible. If it's there, the player will get it; use it to advertise the offer ("$4.99/mo for the first 3 months, then $9.99/mo") and treat it as display-only.- A subscription can have both a free trial and an intro offer: after the trial ends, the subscriber pays the intro price for the configured number of months, then the standard price.
Retention discounts
A subscription can also configure a retention discount — a discounted price a current subscriber can claim once, configured per-product in the Developer Console next to the intro offer. It exists so your game can run its own retention flow when a player asks to cancel: instead of losing the subscriber, offer them the discount.
How it works:
- While a player is entitled and still eligible,
SubscriptionDatacarries the offer as a nullableretentionOfferfield (priceanddurationPeriods). Use it to phrase your pitch ("stay for $4.99/mo for the next 3 months?") and to decide whether a pitch is possible at all. - If the player accepts, call
claimRetentionOffer({ subscriptionSku }). The discount is applied to their existing subscription instantly — no checkout, no new product. Their nextdurationPeriodsrenewals bill at the discounted price, then the standard price returns automatically. - Each player can claim a subscription's retention discount once, and not during a free trial or while an introductory offer window is still running. When ineligible the field is
nulland the call returnsnot_eligible. Repeating the call for a subscription the player already claimed re-confirms the same discount and returnssuccess— safe to retry after an error. - If the player declines, fall through to
cancelSubscriptionas usual.
const { subscriptions } = await JestSDK.payments.getSubscriptions();
const sub = subscriptions.find((s) => s.sku === "premium");
// Player tapped "cancel" in your UI:
if (sub?.retentionOffer) {
// Show your pitch. If accepted:
const result = await JestSDK.payments.claimRetentionOffer({
subscriptionSku: "premium",
});
if (result.result === "success") {
// result.subscription reflects the post-claim state.
return;
}
}
await JestSDK.payments.cancelSubscription({ subscriptionSku: "premium" });
How to use the SDK
Payload shapes
Where the SDK references SubscriptionData, it contains:
{
sku: string; // the subscription's SKU, configured in the Developer Console
displayName: string; // suitable for display in your game's UI
displayDescription: string | null; // optional short description
price: number; // the subscription price in the currency specified in `currency`
currency: string; // the currency (ISO 4217 code) the price is in
billingPeriod: "weekly" | "monthly" | "yearly"; // the billing cadence
status: "active" | "inactive"; // the wallet's current entitlement for this subscription
trialEligible: boolean; // whether the wallet can still start this subscription's free trial
introOffer: {
price: number; // the discounted price, in the currency specified in `currency`
durationPeriods: number; // number of billing periods the discounted price applies
} | null; // null when no intro offer is configured or the wallet is not eligible
retentionOffer: {
price: number; // the discounted price, in the currency specified in `currency`
durationPeriods: number; // number of billing periods, starting at the next renewal
} | null; // null unless the wallet holds this subscription and can still claim the discount
sandbox?: true; // present only for sandbox users and in the simulator
}
estimatedRevenueThe estimatedRevenue field has been deprecated. It now always returns 0 and will be removed entirely in a future SDK release. Do not rely on its value.
A status of "active" means the player's wallet currently has the entitlement and should have access to whatever the subscription unlocks. "inactive" means they don't have it (either never subscribed, or it has expired/been cancelled).
Set up subscriptions
Set up and price subscriptions using the Jest Developer Console.
For more information, see Manage subscriptions.
List subscriptions (getSubscriptions)
If a player has ended their subscription, but it is still within their last paid billing period, it will remain "active" and be returned in the getSubscriptions() response until the billing period ends.
To retrieve the subscriptions available to the player along with their current entitlement, call getSubscriptions.
// Returns a promise resolving to the subscription catalog plus the wallet's
// current entitlement status for each subscription.
const { subscriptions, signed } = await JestSDK.payments.getSubscriptions();
for (const subscription of subscriptions) {
if (subscription.status === "active") {
unlockFeaturesFor(subscription.sku);
}
}
The response contains:
| Property | Note |
|---|---|
subscriptions | The array of SubscriptionData objects (see Payload shapes). |
signed | A signed JWT carrying the same subscriptions array. See below. |
For guest players, getSubscriptions returns an empty subscriptions array
Displaying prices
The way subscription prices are displayed should be based on the price, currency, and billingPeriod. currency is an ISO 4217 code which can be used to select the correct currency symbol or formatting.
Start a subscription (beginSubscription)
Call beginSubscription with the sku of a subscription returned by getSubscriptions().
The method returns one of the following results. In mock mode, you can simulate each outcome using the JestSDK debug menu.
Success
{ result: "success"; subscription: SubscriptionData; subscriptionSigned: string }Checkout completed successfully. The returned subscription is now"active"for the player's wallet.
Cancellation
{ result: "cancel" }The player closed or abandoned the checkout flow.
Error
{ result: "error"; error: string }One of the following error codes is returned:internal_error- A transient error occurred. Your game may retry.invalid_subscription- The requestedskuis not available. Do not retry with the samesku. If this persists and the subscription configuration appears correct, contact support.already_subscribed- The player's wallet already has an active entitlement for this subscription. Refresh the wallet's state viagetSubscriptions().guest_not_allowed- The player is a guest. The platform automatically shows a signup gate before returning this error, so your game does not need to prompt for registration — handle the error gracefully.
Any other error (for example, a timeout) should be handled by your game and may be retried.
beginSubscription may also throw (for example, due to a timeout). Treat thrown errors as retryable.
const { subscriptions } = await JestSDK.payments.getSubscriptions();
const premium = subscriptions.find((s) => s.sku === "premium_monthly");
if (!premium || premium.status === "active") {
// Already entitled; no need to start checkout.
return;
}
const result = await JestSDK.payments.beginSubscription({
subscriptionSku: premium.sku,
});
if (result.result === "cancel") {
// Handle cancellation with UI feedback.
return;
}
if (result.result === "error") {
if (result.error === "guest_not_allowed") {
// The platform already showed a signup gate; no need to prompt.
return;
}
if (result.error === "already_subscribed") {
// Re-read entitlement state and unlock features.
return;
}
// internal_error / invalid_subscription: handle with UI feedback.
return;
}
// result.result === "success"
const { subscription, subscriptionSigned } = result;
unlockFeaturesFor(subscription.sku);
After a successful beginSubscription, prefer using the returned subscription (or, better, subscriptionSigned) to unlock features immediately. The next getSubscriptions() call will return the same "active" status.
Signed subscription data (JWT)
The beginSubscription and getSubscriptions methods return subscription data in two forms:
- As plain objects (
subscription/subscriptions) for convenience. - As signed tokens (
subscriptionSigned/subscriptionsSigned) in the form of a signed JSON Web Token (JWT).
The data inside the signed token is equivalent to the plain object. For critical actions such as unlocking paid content or applying entitlements server-side, you must only trust the signed token after verifying its signature.
You can use any standard JWT/JWS library to verify the token signature using your game's shared secret and extract the subscription data.
Failure to verify the signed token can leave your game vulnerable to exploitation. Players can otherwise spoof an "active" status without paying.
Shared secret
Each game has a shared secret configured in the Developer Console (see Games > Secrets). This secret is provided as a base64-encoded string and must be kept confidential between you and the Jest platform.
The same shared secret is used to verify the signed tokens from Payments and Player.
If the shared secret is ever leaked, rotate it immediately by generating a new one in the Developer Console.
Signed payload shapes
Signed JWT payload from beginSubscription (subscriptionSigned):
{
subscription: SubscriptionData; // the single subscription that was just activated
aud: string; // game ID as the 'audience' claim
sub: string; // player ID as the 'subject' claim
}
Signed JWT payload from getSubscriptions (subscriptionsSigned):
{
subscriptions: SubscriptionData[]; // the player's full subscription catalog with current statuses
aud: string; // game ID as the 'audience' claim
sub: string; // player ID as the 'subject' claim
}
Signed payloads may include additional standard JWT claims (such as iat).
Verifying and decoding (server-side)
Use a standard JWT/JWS library on your backend to verify the token signature and decode its payload.
The verification process is the same as for the token returned by JestSDK.getPlayerSigned(). For server-side verification examples in other languages, see Player: JestSDK.getPlayerSigned().
To ensure the subscription data is correct and applies to the expected game and player, validate the aud (audience) and sub (subject) claims:
aud: your game ID (available in the Developer Console)sub: the player ID (fromJestSDK.getPlayer())
Always validate sub. Without it, your backend will accept a legitimately-signed token from player A and silently apply it to player B's entitlements — a cross-player substitution that doesn't require forging anything. Take the sub claim from the verified subscription token, compare it against the expected player ID on your backend, and reject the request if they don't match.
If you don't provide aud and sub as parameters to your JWT library, you are responsible for verifying them on the decoded payload.
An example (Node.js) using jose:
This example is server-side.
import { jwtVerify } from "jose";
// This value should be kept secret and NOT checked into source control
// or used client-side.
const sharedSecret = "<your game's shared secret>";
const gameId = "<your game id>";
// Resolve this from the verified `playerSigned` token's player ID, on your
// backend. Do not trust a player ID supplied by the client.
const expectedPlayerId = "<player id for the current player>";
// The `signed` value from the `getSubscriptions` response, or the
// `subscriptionSigned` value from `beginSubscription`.
// Send it to your server *without* any modification.
const token = "<token>";
const verified = await jwtVerify(token, Buffer.from(sharedSecret, "base64"), {
audience: gameId,
subject: expectedPlayerId,
});
// The verified payload contains { subscriptions, aud, sub } (or
// { subscription, aud, sub } for `beginSubscription`) plus standard JWT claims.
console.log(verified.payload);
We strongly recommend using an established library to verify JWTs rather than implementing verification yourself. If for whatever reason this doesn't happen, your implementation must follow the JWT spec and current best practices.
Testing
When using a sandbox user, the Stripe checkout flow still needs to be completed — the player goes through the same checkout UI as a normal player — but the order total is $0 and no real charge is made. Once checkout is completed, the resulting subscription is treated as "active" for the duration of a normal billing period, so you can test the subscribed and unsubscribed flows end-to-end without spending real money.
If the subscription configures a free trial, a sandbox user receives it on their first subscribe (at $0, with no payment method required), so you can test the trial flow too.
Every SubscriptionData returned to a sandbox user carries sandbox: true, including inside the signed token, so your backend can tell a test subscription from a paying one. price stays as configured — only the amount actually billed is $0 — so the flag is the only reliable signal here. introOffer and retentionOffer are always null for a sandbox user, since a checkout already forced to $0 carries no discount; test those flows with a real player, or in the Simulator. Grant the entitlement as usual, and keep sandbox subscribers out of revenue reporting.
In mock mode, you can simulate each beginSubscription outcome (success, cancel, errors) and toggle the wallet's entitlement on each subscription SKU via the JestSDK debug menu.