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 Payment 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.- It is also always
nullfor sandbox users, so intro offers never show up while you test with a sandbox account. - A subscription can have both a free trial and an intro offer. The discount window is measured from signup, trial included, but trials are capped at 14 days, so the subscriber still receives every configured discounted month after the trial ends.
Intro offers apply only to a wallet's first subscription to a product, so they can't be used to win back a cancelling subscriber. Subscribing that player to a second SKU leaves the original subscription running and bills them for both — BeginSubscription only rejects checkout for a SKU the player is already entitled to. For cancel flows, use a retention discount (see Retention discounts), which applies to the subscription the player already has.
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. - Sandbox users never receive one:
RetentionOfferstaysnullhowever they subscribed, andClaimRetentionOfferreturnsnot_eligible. Test the flow in mock mode instead. - If the player declines, fall through to
CancelSubscriptionas usual.
var payment = JestSDK.Instance.Payment;
var subscriptionsResponse = await payment.GetSubscriptions();
var sub = subscriptionsResponse.Subscriptions.Find(s => s.Sku == "premium");
// Player tapped "cancel" in your UI:
if (sub?.RetentionOffer != null)
{
// Show your pitch:
if (await PlayerAcceptsRetentionOffer(sub.RetentionOffer))
{
Payment.ClaimRetentionOfferResult result = await payment.ClaimRetentionOffer("premium");
if (result.Result == "error")
{
// Claiming again is safe, so offer a retry instead of cancelling a player who chose to stay.
ShowRetentionClaimError(result.Error);
}
// On success, result.Subscription reflects the post-claim state.
return;
}
}
await payment.CancelSubscription("premium");
How to use the SDK
Payload shapes
Where the SDK references SubscriptionData, it contains:
public class SubscriptionData
{
public string Sku; // the subscription's SKU, configured in the Developer Console
public string DisplayName; // suitable for display in your game's UI
public string DisplayDescription; // optional short description; null when not set
public decimal Price; // the subscription price in the currency specified in Currency
public string Currency; // the currency (ISO 4217 code) the price is in
public string BillingPeriod; // "weekly", "monthly", or "yearly"
public string Status; // "active" or "inactive"
public bool TrialEligible; // whether the wallet can still start this subscription's free trial
public IntroOfferData IntroOffer; // discounted price for the first billing periods; null when not eligible
public RetentionOfferData RetentionOffer; // null unless the wallet holds this subscription and can still claim the discount
public bool? Sandbox; // present only for sandbox users and in the simulator
}
public class IntroOfferData
{
public decimal Price; // the discounted price, in the currency specified in Currency
public int DurationPeriods; // number of billing periods the discounted price applies
}
public class RetentionOfferData
{
public decimal Price; // the discounted price, in the currency specified in Currency
public int DurationPeriods; // number of billing periods, starting at the next renewal
}
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)
To retrieve the subscriptions available to the player along with their current entitlement, call GetSubscriptions.
var payment = JestSDK.Instance.Payment;
try
{
Payment.GetSubscriptionsResponse response = await payment.GetSubscriptions();
foreach (var subscription in response.Subscriptions)
{
if (subscription.Status == "active")
{
UnlockFeaturesFor(subscription.Sku);
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to load subscriptions: {ex.Message}");
}
The response contains:
| Property | Note |
|---|---|
Subscriptions | The list 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 list and an empty Signed string. Subscriptions are only available to registered users.
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 a SubscriptionResult with one of the following outcomes. In mock mode, you can simulate each outcome using the JestSDK debug menu.
Success
Result == "success"withSubscriptionandSubscriptionSignedpopulated. 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"withErrorcontaining one of: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.
var payment = JestSDK.Instance.Payment;
try
{
var subscriptionsResponse = await payment.GetSubscriptions();
var premium = subscriptionsResponse.Subscriptions
.Find(s => s.Sku == "premium_monthly");
if (premium == null || premium.Status == "active")
{
// Already entitled, or offering not available; nothing to do.
return;
}
Payment.SubscriptionResult result = await payment.BeginSubscription(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"
var subscription = result.Subscription;
var subscriptionSigned = result.SubscriptionSigned;
UnlockFeaturesFor(subscription.Sku);
}
catch (Exception ex)
{
Debug.LogError($"Subscription failed: {ex.Message}");
}
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.
Cancel a subscription (CancelSubscription)
Call CancelSubscription with the Sku of an active subscription. The platform opens a confirmation dialog with the player; if the player confirms, the subscription is cancelled at the end of the current billing period (the player retains the entitlement until then).
The method returns a CancelSubscriptionResult with one of:
Success
Result == "success"The player confirmed the cancellation. The subscription will remain"active"until the end of the current billing period, then transition to"inactive"on the nextGetSubscriptions()call.
Cancellation
Result == "cancel"The player dismissed the confirmation dialog without cancelling the subscription. No change is made.
Error
Result == "error"withErrorcontaining one of:internal_error- A transient error occurred. Your game may retry.not_found- The requestedSkudoes not correspond to a known subscription. Do not retry with the sameSku.not_active- The player's wallet does not have an active entitlement for this subscription. Refresh state viaGetSubscriptions().guest_not_allowed- The player is a guest. Guests cannot have subscriptions to cancel.
var payment = JestSDK.Instance.Payment;
try
{
Payment.CancelSubscriptionResult result = await payment.CancelSubscription("premium_monthly");
if (result.Result == "cancel")
{
// Player dismissed the confirmation dialog; nothing to do.
return;
}
if (result.Result == "error")
{
Debug.LogError($"Cancel failed: {result.Error}");
return;
}
// result.Result == "success"
// The subscription will lapse at the end of the current billing period.
// Re-read GetSubscriptions() the next time you need authoritative state.
}
catch (Exception ex)
{
Debug.LogError($"Cancel failed: {ex.Message}");
}
Cancellation only schedules the subscription to lapse at the end of the current billing period. The entitlement remains "active" until then, and your game should continue to unlock the relevant features for the remainder of the period.
Claim a retention offer (ClaimRetentionOffer)
Call ClaimRetentionOffer with the Sku of a subscription the player currently holds, when its RetentionOffer is non-null (see Retention discounts). The discount is applied to their existing subscription instantly — no checkout.
The method returns a ClaimRetentionOfferResult with one of:
Success
Result == "success"withSubscriptionandSubscriptionSignedpopulated. The discount was applied. The returned subscription reflects the post-claim state (RetentionOfferis nownull).
Error
Result == "error"withErrorcontaining one of:internal_error- A transient error occurred. Your game may retry.not_eligible- The player's wallet cannot claim this subscription's retention discount (already claimed, not entitled, or an introductory offer window is still running). Refresh state viaGetSubscriptions().guest_not_allowed- The player is a guest. Guests cannot have subscriptions to claim a discount on.
var payment = JestSDK.Instance.Payment;
try
{
Payment.ClaimRetentionOfferResult result = await payment.ClaimRetentionOffer("premium_monthly");
if (result.Result == "error")
{
Debug.LogError($"Claim retention offer failed: {result.Error}");
return;
}
// result.Result == "success"
var subscription = result.Subscription;
UnlockFeaturesFor(subscription.Sku);
}
catch (Exception ex)
{
Debug.LogError($"Claim retention offer failed: {ex.Message}");
}
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/Signed) 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.
For server-side verification examples and details, see the HTML5 SDK Subscriptions documentation.
Failure to verify the signed token can leave your game vulnerable to exploitation. Players can otherwise spoof an "active" status without paying.
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 and CancelSubscription outcome (success, cancel, errors), each ClaimRetentionOffer outcome (success, errors), and toggle the wallet's entitlement on each subscription SKU via the JestSDK debug menu.