Skip to main content

Payments

note

We are in the process of deprecating credits for product purchases. Prices will be specified by price and currency properties.

The Jest platform allows developers to sell in-game products using the Payment SDK methods. We enable these transactions primarily through popular digital wallets, such as Apple Pay and Google Wallet. Jest handles the entire checkout process with the player, while your game is responsible for granting the purchased items and confirming the purchase.

Payments on Jest
Payments on Jest via digital wallets

Pricing

Players purchase in-game products in USD on the Jest platform.

As a developer, you define the products and USD prices available in your game. When your game calls beginPurchase, Jest handles checkout end-to-end with the player.

note

When using a sandbox user, players see real product prices in the game UI, but the platform checkout modal makes clear that no charge will be made. The resulting PurchaseData records 0 in both price and credits (deprecated).

Purchase lifecycle

A purchase represents a single successful checkout of a product by a specific player.

  1. Display available products to the player (via getProducts()).
  2. Start checkout by calling beginPurchase({ productSku }).
  3. If checkout succeeds, the SDK returns an incomplete purchase containing a purchaseToken.
  4. Grant the purchased item in your game, then confirm the purchase by calling completePurchase({ purchaseToken }).
  5. Until completed, the purchase remains incomplete and will continue to be returned by getIncompletePurchases() (for example, after a crash).

Checkout cancellation and error handling are managed by your game; see Start a purchase (beginPurchase) for details.

Happy path (checkout succeeds)

Recovery (startup reconciliation)

If checkout succeeds but the game crashes, loses connectivity, or is closed before completePurchase is called, the purchase remains incomplete. The platform will keep returning it via getIncompletePurchases() until you confirm it.

If hasMore is true, continue fetching until all incomplete purchases are processed.

How to use the SDK

Payload shapes

Where the SDK references PurchaseData, it contains:

{
purchaseToken: string; // required to call `completePurchase(...)`
productSku: string; // the product purchased
credits: number; // (Deprecated, use price and currency instead) the total USD value of the transaction
createdAt: number; // JS timestamp (ms since epoch)
completedAt: number | null; // JS timestamp (ms since epoch); null until confirmed
estimatedRevenue: number; // the (estimated) USD share of revenue from this purchase that the publisher will receive
price: number; // the purchase price in the currency specified in `currency`
currency: string; // the currency (ISO 4217 code) the price is in
}

Set up products

Set up and price products using the Jest Developer Console.

For more information, see Manage products.

List products (getProducts)

To retrieve the products available for purchase in your game, call getProducts.

// Returns a promise resolving to an array of products
const products = await JestSDK.payments.getProducts();

Products include the following information:

PropertyNote
skuAn identifier for the product, configured when set up in the Developer Console.
nameThe display name of the product that may be shown to the player during checkout.
descriptionNullable short description of the product that may be shown to the player during checkout.
priceProduct price in the currency specified in currency
currencyThe currency code (ISO 4217) the price is in

Displaying prices

The way product prices are displayed should be based on the price and the currency. currency is an ISO 4217 which can be used to select the correct currency symbol or formatting.

Start a purchase (beginPurchase)

Call beginPurchase with the sku of a product returned by getProducts().

The method returns one of the following results. In mock mode, you can simulate each outcome using the JestSDK debug menu.

Success

  • { result: "success"; purchase: PurchaseData; purchaseSigned: string }
    Checkout completed successfully. The returned purchase is incomplete and must be granted and confirmed by your game.

Cancellation

  • { result: "cancel" }
    The player canceled 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 the purchase.
    • invalid_product - The requested sku is not available for purchase. Do not retry with the same sku. If this persists and the product configuration appears correct, contact support.

Any other error (for example, a timeout) should be handled by your game and may be retried.

note

beginPurchase may also throw (for example, due to a timeout). Treat thrown errors as retryable.

const products = await JestSDK.payments.getProducts();
const product = products[0];

const beginPurchaseResult = await JestSDK.payments.beginPurchase({
productSku: product.sku,
});

if (beginPurchaseResult.result === "cancel") {
// Handle cancellation with UI feedback
return;
}

if (beginPurchaseResult.result === "error") {
// Handle the error with different product or a retry
return;
}

// result === "success"
const { purchase, purchaseSigned } = beginPurchaseResult;

Grant and confirm a purchase (completePurchase)

When beginPurchase returns success, your game must:

  1. Grant the purchased product to the player.
  2. Confirm the purchase by calling completePurchase({ purchaseToken }).

Until completePurchase is called, the purchase remains incomplete and will continue to be returned by getIncompletePurchases().

warning

Only call completePurchase after you have durably granted the purchase (for example, your backend has verified the signed data and recorded the grant). If you confirm first and then crash before granting, getIncompletePurchases() cannot recover the purchase because it's already confirmed.

completePurchase returns:

  • Success ({ result: "success" }).
  • Error ({ result: "error"; error: "internal_error" | "invalid_token" }).
    • internal_error: a transient error occurred; retry.
    • invalid_token: the token is not valid (already confirmed, wrong player, etc.); don't retry with the same token.
tip

If you have a backend, treat purchase.purchaseToken as an idempotency key and store it so you never grant the same purchase twice, including during retries or recovery flows.

// Recommended: send `purchaseSigned` to your backend to verify the purchase and grant the item.
// Your backend should store `purchase.purchaseToken` as an idempotency key to prevent double grants.
await verifyAndGrantPurchaseServerSide({ purchaseSigned });

// Only confirm after the grant succeeded.
const completePurchaseResult = await JestSDK.payments.completePurchase({
purchaseToken: purchase.purchaseToken,
});

if (completePurchaseResult.result === "error") {
if (completePurchaseResult.error === "internal_error") {
// Retry later. Leaving the purchase incomplete is safe; it will be returned by getIncompletePurchases().
} else {
// invalid_token: don't retry with the same token.
}
}
Client-only grant (not recommended)

If your game has no backend, you can grant based on purchase.productSku and then confirm the purchase. This approach is vulnerable to tampering and should only be used for prototypes or internal testing. Never ship your game's shared secret in the client.

grantItem(purchase.productSku);

await JestSDK.payments.completePurchase({
purchaseToken: purchase.purchaseToken,
});

Recover incomplete purchases (getIncompletePurchases)

Your game must check for incomplete purchases every time it starts. This is what makes purchases resilient to crashes, power loss, and network failures between a successful checkout and completePurchase.

To recover incomplete purchases, call payments.getIncompletePurchases on startup and:

  1. Grant any incomplete purchases using the productSku.
  2. Confirm them using the corresponding purchaseToken.

The response is capped (currently 50 purchases per call). If hasMore is true, confirm the returned purchases and call again until it is false.

let result: {
hasMore: boolean;
purchases: PurchaseData[]; // see 'Payload shapes'
purchasesSigned: string;
};

do {
result = await JestSDK.payments.getIncompletePurchases();

// Recommended: send `result.purchasesSigned` to your backend to verify and grant purchases idempotently.
const verified = await verifyAndGrantPurchasesServerSide({
purchasesSigned: result.purchasesSigned,
});

for (const purchase of verified.purchases) {
// At this point your backend has verified and granted this purchase.
const completion = await JestSDK.payments.completePurchase({
purchaseToken: purchase.purchaseToken,
});

if (completion.result === "error") {
if (completion.error === "internal_error") {
// Retry later. Leaving it incomplete is safe; it will be returned again.
return;
}

// invalid_token: don't retry with the same token.
}
}
} while (result.hasMore);
Client-only recovery (not recommended)

If your game has no backend, you can iterate result.purchases, grant based on purchase.productSku, and then call completePurchase for each purchaseToken. This is vulnerable to tampering. Never ship your game's shared secret in the client.

Signed purchase data (JWT)

The beginPurchase and getIncompletePurchases methods return purchase data in two forms:

  1. As plain objects (purchase / purchases) for convenience.
  2. As signed tokens (purchaseSigned / purchasesSigned) 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 granting items or crediting accounts, 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 purchase data.

warning

Failure to verify the signed token can leave your game vulnerable to exploitation.

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.

warning

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 beginPurchase (purchaseSigned):

{
purchase: PurchaseData; // a single purchase
aud: string; // game ID as the 'audience' claim
sub: string; // player ID as the 'subject' claim
}

Signed JWT payload from getIncompletePurchases (purchasesSigned):

{
purchases: PurchaseData[]; // an array of purchases
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 purchase data is correct and applies to the expected game and player, validate the following aud (audience) and sub (subject) claims:

  • aud: your game ID (available in the Developer Console)
  • sub: the player ID (from JestSDK.getPlayer())
warning

If you don't provide these as parameters to your JWT library, you are responsible for verifying them on the decoded payload.

An example (Node.js) using jose:

note

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>";
const playerId = "<player id from JestSDK.getPlayer().playerId>";

// The `purchaseSigned` value from the `beginPurchase` response.
// Send it to your server *without* any modification.
const purchaseSigned = "<token>";

const verified = await jwtVerify(
purchaseSigned,
Buffer.from(sharedSecret, "base64"),
{
audience: gameId,
subject: playerId,
},
);

// The verified payload contains { purchase, aud, sub } and 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.

Revenue estimate

Jest's economics model splits revenue between the publisher, the platform and publishers who bring users in to the platform. PurchaseData includes an estimate, in USD, of the share you (as the publisher) are entitled to after the purchase is completed.

At the time of purchase, this is an estimate only, as the final revenue may depend on player subscription or other factors. See our economics model for more information.

Final revenue will be reported via the developer console (details coming soon).