Skip to main content

Payments

The Jest platform allows developers to sell in-app products using the payments 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 user, while your app is responsible for granting the purchased items and confirming the purchase.

Payments on Jest
Payments on Jest via digital wallets

Pricing

Users purchase in-app products in USD on the Jest platform.

As a developer, you define the products and USD prices available in your app. When your app starts a purchase, Jest handles checkout end-to-end with the user.

note

When using a sandbox user, users see real product prices in the app UI, but the platform checkout modal makes clear that no charge will be made. The resulting PurchaseData records 0 in price and carries a sandbox flag so you can recognize a test purchase without inferring it from the price. See Recognize sandbox purchases.

Purchase lifecycle

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

  1. Display available products to the user (see List products).
  2. Start checkout with the product sku (see Start a purchase).
  3. If checkout succeeds, the SDK returns an incomplete purchase containing a purchaseToken.
  4. Grant the purchased item in your app, then confirm the purchase using the purchaseToken (see Grant and confirm a purchase).
  5. Until completed, the purchase remains incomplete and will continue to be returned as an incomplete purchase (for example, after a crash). See Recover incomplete purchases.

Checkout cancellation and error handling are managed by your app; see Start a purchase for details.

Happy path (checkout succeeds)

Recovery (startup reconciliation)

If checkout succeeds but the app crashes, loses connectivity, or is closed before the purchase is confirmed, the purchase remains incomplete. The platform will keep returning it as an incomplete purchase 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
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
sandbox?: true; // present only for sandbox-user or simulator purchases
}

Set up products

Set up and price products using the Jest Developer Console.

For more information, see Manage products.

List products

To retrieve the products available for purchase in your app, list the products.

JestSDK.payments.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 user during checkout.
descriptionNullable short description of the product that may be shown to the user 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

Start a purchase with the sku of a product returned when listing products.

The method returns one of the following results:

Success

  • result is "success", with purchase (a PurchaseData) and purchaseSigned (a string) populated. Checkout completed successfully. The returned purchase is incomplete and must be granted and confirmed by your app.

Cancellation

  • result is "cancel". The user canceled the checkout flow.

Error

  • result is "error", with error containing one of the following error codes:
    • internal_error - A transient error occurred. Your app 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 app and may be retried.

In mock mode, you can simulate each outcome using the JestSDK debug menu.

JestSDK.payments.beginPurchase({ productSku })

The result is one of { result: "success"; purchase: PurchaseData; purchaseSigned: string }, { result: "cancel" }, or { result: "error"; error: string }.

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

When a purchase returns success, your app must:

  1. Grant the purchased product to the user.
  2. Confirm the purchase using its purchaseToken.

Until the purchase is confirmed, it remains incomplete and will continue to be returned as an incomplete purchase.

warning

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

Confirming a purchase returns:

  • Success (result is "success").
  • Error (result is "error") with error being one of internal_error or invalid_token:
    • internal_error: a transient error occurred; retry.
    • invalid_token: the token is not valid (already confirmed, wrong user, 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.

JestSDK.payments.completePurchase({ purchaseToken })

Returns { result: "success" } or { result: "error"; error: "internal_error" | "invalid_token" }.

// Recommended: send `purchaseSigned` to your backend to verify the purchase and grant the item.
// Store `purchase.purchaseToken` on your backend 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. An incomplete purchase is safe: getIncompletePurchases() returns it again.
} else {
// invalid_token: don't retry with the same token.
}
}
Client-only grant (not recommended)

If your app 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 app's shared secret in the client.

grantItem(purchase.productSku);

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

Recover incomplete purchases

Your app 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 confirming the purchase.

To recover incomplete purchases, fetch them 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.

JestSDK.payments.getIncompletePurchases()

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

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

// Recommended: send `result.purchasesSigned` to your backend; verify and grant 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 app has no backend, you can iterate the returned purchases, grant based on purchase.productSku, and then confirm each purchaseToken. This is vulnerable to tampering. Never ship your app's shared secret in the client.

Recognize sandbox purchases

Purchases made by a sandbox user, and purchases driven from the Developer Console simulator, carry a sandbox flag set to true. The flag is absent on real purchases, so check for it being true; it travels inside the signed token too — your backend can trust it after verifying the signature.

Sandbox purchases are also priced at 0; simulator purchases keep the price you configured, so the flag is the only reliable signal there.

The flag is the optional sandbox field on PurchaseData, so purchase.sandbox === true is the check.

const result = await JestSDK.payments.beginPurchase({ productSku: "gem_pack" });

if (result.result === "success") {
// Grant either way; only the bookkeeping differs.
await grantItem(result.purchase.productSku);

if (!result.purchase.sandbox) {
reportRevenue(result.purchase.price, result.purchase.currency);
}
}

Grant the item either way — that is what makes the sandbox useful for testing your full purchase flow. Use the flag to keep test traffic out of anything that counts real money: revenue analytics, leaderboards of top spenders, lifetime-value models, or payouts.

Signed purchase data (JWT)

Starting a purchase and recovering incomplete purchases both 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).

beginPurchase returns purchase and purchaseSigned; getIncompletePurchases returns purchases and purchasesSigned.

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 app's shared secret and extract the purchase data.

warning

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

Shared secret

Each app has a shared secret configured in the Developer Console (see Apps > 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 starting a purchase (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 recovering incomplete purchases (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 signed player token. For server-side verification examples in other languages, see Player.

To ensure the purchase data is correct and applies to the expected app and user, validate the following aud (audience) and sub (subject) claims:

  • aud: your game ID (available in the Developer Console)
  • sub: the player ID (see Player)
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 of the current player>";

// The signed purchase token (`purchaseSigned`) returned by the SDK.
// 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.