Search documentation

Find pages, sections, and content across all docs.

WavedashDocs

Consumables

Purchases that deplete during gameplay and can be purchased again

A consumable is a purchase the player can make again and again (ie in-game currency, extra lives, extra time). Unlike a durable, it doesn't gate any build files. Your game decides what effect a purchase has in-game and applies it, then tells Wavedash the purchase is fulfilled.

Creating a consumable

  1. In the Developer Portal, open your game's Monetization tab and click Paid Content → Add, then choose Consumable.
  2. Set a Content identifier, like coins-500. Your game passes it to the SDK. It's up to 32 letters, numbers, hyphens, or underscores, and can't be changed later.
  3. Set the Price in USD, from $0.99 to $99.99.
  4. Choose the Visibility: Playtest offers it only in the playtest environment while you develop, and Live offers it on your game page.
  5. Write the paywall's Modal copy: title, message, feature list, and button label. The preview shows what players will see.
  6. Click Create.

How a purchase flows

  1. The player buys the consumable, either from a paywall your game opens with triggerPaywall or from your game's store page.
  2. Wavedash tells you about it in two ways. The SDK fires a PURCHASE_COMPLETED event in the running game, and, if you've set a webhook URL, Wavedash sends a purchase.completed webhook to your backend. Use whichever event suits your game.
  3. You apply the effect, such as adding 500 coins to the player's balance.
  4. You mark the purchase fulfilled, either with Wavedash.fulfillPurchase(purchaseId) from the SDK or with the HTTP API from your backend.

Until a purchase is fulfilled, the SDK delivers it again every time the game launches. This catches any purchases made while the game was closed, as well as the browser tab closing between payment and grant.

Every purchase has a purchaseId, and every delivery comes with a signed receiptJwt. The same purchase can reach you more than once: after a relaunch, through both the event and the webhook, or from a webhook retry. Record which purchaseIds you've granted, and grant each one only once.

While you can apply the purchase on the client side through your game, server side APIs provide additional protection against issues like poor network connectivity and malicious activity. If your game has a backend, send the receiptJwt there and grant purchases server side. If your game has no backend, grant purchases in the game and call fulfillPurchase.

Opening the paywall

triggerPaywall opens the checkout for a consumable and resolves with whether the player bought it. Unlike a Durable, it never resolves true immediately, because a consumable is never "owned": every call opens the paywall. For the same reason, isEntitled and getEntitlements only cover durables. isEntitled is always false for a consumable, and getEntitlements never lists one. Track what the player has in your own save or backend.

Don't grant from the paywall result. The PURCHASE_COMPLETED event fires for this purchase too, and it's the only path that also covers store-page purchases and redelivery at launch.

func on_buy_coins_pressed():
    # Granting happens in the purchase_completed handler, not here.
    var result = await WavedashSDK.trigger_paywall("coins-500")
    if not (result.success and result.data):
        show_purchase_dismissed_message()
// Granting happens in OnPurchaseCompleted, not here.
bool purchased = await Wavedash.SDK.TriggerPaywall("coins-500");
if (!purchased)
    ShowPurchaseDismissedMessage();
local co = coroutine.create(function()
    -- Granting happens in the PURCHASE_COMPLETED handler, not here.
    local result = wavedash.trigger_paywall_async("coins-500")
    if not (result.success and result.data) then
        show_purchase_dismissed_message()
    end
end)
assert(coroutine.resume(co))
// Granting happens in the PURCHASE_COMPLETED handler, not here.
const result = await Wavedash.triggerPaywall("coins-500");
if (!result.success || !result.data) showPurchaseDismissedMessage();

Granting on your backend

If your game has a backend, apply the purchase there. The balance then lives somewhere a modified client can't tamper, and the receiptJwt proves the purchase is real.

2 options for notifying your backend about the purchase. Both delivery paths carry the same receiptJwt, so you can use either one, or both. Because you grant each purchaseId only once, the second path to arrive changes nothing.

  1. From the game. In your PURCHASE_COMPLETED handler (payload), send the receiptJwt to your backend along with the player's user JWT. This works without any webhook setup, but only while the game is running.
  2. From a webhook. Wavedash POSTs the receipt to your backend as soon as the purchase completes, even if the game is closed. Your backend has to know which of its players the receipt's sub belongs to. See Matching purchases to players.

Forwarding purchases from the game

Wavedash.on(Wavedash.Events.PURCHASE_COMPLETED, async (purchase) => {
  if (purchase.type !== Wavedash.PurchaseType.CONSUMABLE) return;
  const userJwt = await Wavedash.getUserJwt();
  if (!userJwt.success) return; // Delivered again at next launch

  const res = await fetch("https://api.yourgame.com/purchases", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${userJwt.data}`,
    },
    body: JSON.stringify({ receiptJwt: purchase.receiptJwt }),
  });
  if (res.ok) showInventory(await res.json());
});

On the backend, verify both tokens, confirm they belong to the same player, grant, and fulfill. Here's a backend example in Node.js with Express and jose. See UserJwtPayload and PurchaseReceiptPayload for every claim in the two tokens.

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

// Wavedash signs both the user JWT and purchase receipts with these keys.
const JWKS = createRemoteJWKSet(
  new URL("https://auth.wavedash.com/.well-known/jwks.json")
);
const WAVEDASH_GAME_ID = process.env.WAVEDASH_GAME_ID!; // game_id in wavedash.toml

// What each consumable grants, keyed by content identifier.
const COINS_PER_PACK: Record<string, number> = { "coins-100": 100, "coins-500": 500 };

// The claims this server reads from each token.
interface Player {
  sub: string;  // Wavedash user ID
  gcid: string; // Game cloud ID (your game in one environment)
}

interface PurchaseReceipt {
  sub: string;  // Wavedash user ID of the buyer
  gcid: string;
  env: "PRODUCTION" | "PLAYTEST";
  event: "purchase.completed" | "purchase.refunded";
  purchaseId: string;
  ptype: "CONSUMABLE" | "DURABLE";
  contentIdentifier: string;
}

async function verifyUserJwt(userJwt: string): Promise<Player> {
  const { payload } = await jwtVerify(userJwt, JWKS, {
    issuer: "https://auth.wavedash.com",
    audience: "gameplay.wavedash.com",
  });
  return payload as unknown as Player;
}

async function verifyReceipt(receiptJwt: string): Promise<PurchaseReceipt> {
  const { payload } = await jwtVerify(receiptJwt, JWKS, {
    issuer: "https://auth.wavedash.com",
    audience: WAVEDASH_GAME_ID,
    typ: "wavedash-purchase+jwt", // So a user JWT can't pass as a receipt
  });
  const receipt = payload as unknown as PurchaseReceipt;
  if (receipt.env !== "PRODUCTION") throw new Error("Not a production purchase");
  return receipt;
}

const app = express();
app.use(express.json());

app.post("/purchases", async (req, res) => {
  const userJwt = req.get("Authorization")?.replace(/^Bearer /, "") ?? "";
  const receiptJwt: string = req.body.receiptJwt;

  let player: Player;
  let receipt: PurchaseReceipt;
  try {
    player = await verifyUserJwt(userJwt);
    receipt = await verifyReceipt(receiptJwt);
  } catch {
    return res.status(401).end();
  }

  // The receipt must belong to the signed-in player, in the same environment.
  if (receipt.sub !== player.sub || receipt.gcid !== player.gcid) {
    return res.status(403).end();
  }
  if (receipt.event !== "purchase.completed" || receipt.ptype !== "CONSUMABLE") {
    return res.status(400).end();
  }

  // Grant once per purchaseId, even if it arrives from the game and the webhook.
  await db.transaction(async (tx) => {
    const isNew = await tx.recordPurchaseIfNew(receipt.purchaseId, receipt.sub);
    if (isNew) await tx.addCoins(receipt.sub, COINS_PER_PACK[receipt.contentIdentifier]);
  });

  // Mark it fulfilled with the same receipt, so the SDK stops delivering it.
  await fetch(`https://api.wavedash.com/api/purchases/${receipt.purchaseId}/fulfill`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ receiptJwt }),
  });

  res.json(await db.getInventory(player.sub));
});

app.listen(3000);

db stands in for your database. Recording the purchaseId and adding the item must happen together, so a purchase that arrives twice is only granted once.

If the fulfill call fails, nothing is lost. The SDK delivers the purchase again at the next launch, the game forwards it again, and your backend skips the grant and retries the fulfill. See the Purchases HTTP API for the endpoint's responses.

Receiving webhooks

With a webhook URL set, Wavedash POSTs each purchase's receiptJwt to your backend. Verify it the same way, grant it once per purchaseId as above, fulfill it, and respond 2xx. See Webhooks for setup, the request format, retries, and a handler.

Verifying the receiptJwt

A receiptJwt is a JWT signed with RS256 by the same keys as the user JWT, published at https://auth.wavedash.com/.well-known/jwks.json. Its header sets typ to wavedash-purchase+jwt. Here's an example of backend verification on a Node.js server with the JWT library jose.

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.wavedash.com/.well-known/jwks.json")
);

// Your game's ID: the game_id in wavedash.toml
const WAVEDASH_GAME_ID = process.env.WAVEDASH_GAME_ID!;

export async function verifyReceipt(receiptJwt: string): Promise<PurchaseReceipt> {
  const { payload } = await jwtVerify(receiptJwt, JWKS, {
    issuer: "https://auth.wavedash.com",
    audience: WAVEDASH_GAME_ID,
    typ: "wavedash-purchase+jwt",
  });
  const receipt = payload as unknown as PurchaseReceipt;
  if (receipt.env !== "PRODUCTION") throw new Error("Not a production purchase");
  return receipt;
}

A verified receipt's payload (also in the types reference as PurchaseReceiptPayload):

interface PurchaseReceipt {
  iss: "https://auth.wavedash.com";
  aud: string;               // your game's ID
  sub: string;               // Wavedash user ID of the buyer
  env: "PRODUCTION" | "PLAYTEST";
  gcid: string;              // game cloud ID, same as the user JWT's gcid
  event: "purchase.completed" | "purchase.refunded";
  purchaseId: string;        // the purchase, same as the event payload's purchaseId
  ptype: "CONSUMABLE" | "DURABLE";
  contentIdentifier: string; // the offer that was bought
  created: number;           // when the event happened (seconds since epoch)
  iat: number;               // when this receipt was signed (seconds since epoch)
  jti?: string;              // webhook deliveries only: the delivery ID
}
ClaimWhat it means
issAlways https://auth.wavedash.com.
audYour game's ID. Reject receipts for any other game.
subThe Wavedash user ID of the player who bought it. It's the same ID as sub in their user JWT and the ID getUserId() returns.
envPRODUCTION for real purchases, PLAYTEST for simulated ones from playtests and wavedash dev. A production backend should only grant PRODUCTION.
gcidThe game cloud (your game in one environment) the purchase belongs to. It matches gcid in the user JWT of a player in the same environment.
eventpurchase.completed when the purchase is made. purchase.refunded when it's refunded, sent by webhook only. Only purchase.completed receipts can fulfill a purchase.
purchaseIdThe purchase. Use it as your idempotency key, and pass it to the fulfill endpoint.
ptypeCONSUMABLE or DURABLE.
contentIdentifierThe content identifier of the offer, which you map to what the purchase grants.
createdWhen the event happened: the purchase for purchase.completed, the refund for purchase.refunded.
iatWhen this particular receipt was signed. One purchase can have many receipts with different iat values, so don't dedupe on it.
jtiOnly on webhook deliveries: the delivery's ID, which stays the same across retries and resends of that delivery.

Receipts have no exp. A purchase stays real, so an old receipt is still valid proof of it, and replaying one is harmless as long as you grant each purchaseId only once. Once Wavedash rotates out the key that signed a receipt, that receipt stops verifying. Verify receipts when they arrive, not days later. Both the SDK and each webhook retry hand you a freshly signed one.

Matching purchases to players

A receipt names the buyer by sub, their Wavedash user ID ("subject" in JWT speak). It's the same ID as the sub in the player's user JWT, so use sub as the key for the player's records and inventory on your backend:

  1. When the player launches your game, have your game send getUserJwt() to your backend. Verify it, and look up or create the player by sub.
  2. When a receipt arrives, from the game or a webhook, verify it and add the purchase to the inventory of the player whose ID is the receipt's sub. If you haven't seen that player yet, create them. While rare, webhooks can technically arrive before the player's first session with your backend.
  3. When the game forwards a receipt itself, also check that the receipt's sub matches the user JWT's sub, so one player can't submit another player's receipt. Matching gcid confirms both tokens come from the same environment.
  4. Store each granted purchaseId with the player. That record is what makes redelivery, webhook retries, and duplicate paths safe.

Playtest and production purchases share a player's sub, so keep them apart. Grant only env: "PRODUCTION" on your production backend, or keep playtest inventory separated by gcid or env.

Refunds

When a purchase is refunded, Wavedash sends a purchase.refunded webhook with a receipt for that purchase. A refunded purchase that was never fulfilled stops being delivered, and fulfillPurchase returns NOT_FOUND for it. You decide whether to take back items from a purchase you've already granted. Because webhooks aren't ordered, remember refunded purchaseIds, so a late purchase.completed for one doesn't grant it.

Granting in the game

This is the simplest setup. The game handles PURCHASE_COMPLETED, applies the purchase, saves, and fulfills. Save the grant before calling fulfillPurchase. If the game closes in between, the purchase is delivered again at the next launch, and the saved purchaseId stops you from granting it twice.

const COINS_PER_PACK = {"coins-100": 100, "coins-500": 500}

func _ready():
    WavedashSDK.purchase_completed.connect(_on_purchase_completed)

func _on_purchase_completed(purchase):
    if purchase.type != WavedashSDK.Constants.PURCHASE_TYPE_CONSUMABLE:
        return
    if not COINS_PER_PACK.has(purchase.contentIdentifier):
        return

    # Grant and save first, keyed by purchaseId so a redelivery can't double-grant.
    if not save.granted_purchase_ids.has(purchase.purchaseId):
        save.coins += COINS_PER_PACK[purchase.contentIdentifier]
        save.granted_purchase_ids.append(purchase.purchaseId)
        save_game()

    var result = await WavedashSDK.fulfill_purchase(purchase.purchaseId)
    if not result.success:
        push_warning(result.message) # Delivered again at next launch
    elif result.data.status == WavedashSDK.Constants.FULFILL_PURCHASE_STATUS_NOT_FOUND:
        # Refunded before it was fulfilled: take the grant back.
        save.coins -= COINS_PER_PACK[purchase.contentIdentifier]
        save_game()
using System.Collections.Generic;
using UnityEngine;

static readonly Dictionary<string, int> CoinsPerPack = new()
{
    ["coins-100"] = 100,
    ["coins-500"] = 500,
};

void Awake()
{
    Wavedash.SDK.OnPurchaseCompleted += OnPurchaseCompleted;
}

async void OnPurchaseCompleted(Dictionary<string, object> purchase)
{
    if ((string)purchase["type"] != WavedashConstants.PurchaseType.CONSUMABLE)
        return;
    var purchaseId = (string)purchase["purchaseId"];
    if (!CoinsPerPack.TryGetValue((string)purchase["contentIdentifier"], out var coins))
        return;

    // Grant and save first, keyed by purchaseId so a redelivery can't double-grant.
    if (!save.GrantedPurchaseIds.Contains(purchaseId))
    {
        save.Coins += coins;
        save.GrantedPurchaseIds.Add(purchaseId);
        SaveGame();
    }

    var result = await Wavedash.SDK.FulfillPurchase(purchaseId);
    if (result == null)
    {
        Debug.LogWarning($"Couldn't fulfill {purchaseId}; delivered again at next launch");
    }
    else if ((string)result["status"] == WavedashConstants.FulfillPurchaseStatus.NOT_FOUND)
    {
        // Refunded before it was fulfilled: take the grant back.
        save.Coins -= coins;
        SaveGame();
    }
}
local COINS_PER_PACK = { ["coins-100"] = 100, ["coins-500"] = 500 }

local function on_purchase_completed(purchase)
    if purchase.type ~= wavedash.PURCHASE_TYPE_CONSUMABLE then
        return
    end
    local coins = COINS_PER_PACK[purchase.contentIdentifier]
    if not coins then
        return
    end

    -- Grant and save first, keyed by purchaseId so a redelivery can't double-grant.
    if not save.granted_purchase_ids[purchase.purchaseId] then
        save.coins = save.coins + coins
        save.granted_purchase_ids[purchase.purchaseId] = true
        save_game()
    end

    local co = coroutine.create(function()
        local result = wavedash.fulfill_purchase_async(purchase.purchaseId)
        if not result.success then
            print(result.message) -- Delivered again at next launch
        elseif result.data.status == wavedash.FULFILL_PURCHASE_STATUS_NOT_FOUND then
            -- Refunded before it was fulfilled: take the grant back.
            save.coins = save.coins - coins
            save_game()
        end
    end)
    assert(coroutine.resume(co))
end

function init(self)
    wavedash.init({}, function(_, event, payload)
        if event == wavedash.EVENT_PURCHASE_COMPLETED then
            on_purchase_completed(payload)
        end
    end)
end
const COINS_PER_PACK = { "coins-100": 100, "coins-500": 500 };

Wavedash.on(Wavedash.Events.PURCHASE_COMPLETED, async (purchase) => {
  if (purchase.type !== Wavedash.PurchaseType.CONSUMABLE) return;
  const coins = COINS_PER_PACK[purchase.contentIdentifier];
  if (!coins) return;

  // Grant and save first, keyed by purchaseId so a redelivery can't double-grant.
  if (!save.grantedPurchaseIds.includes(purchase.purchaseId)) {
    save.coins += coins;
    save.grantedPurchaseIds.push(purchase.purchaseId);
    await saveGame(save);
  }

  const result = await Wavedash.fulfillPurchase(purchase.purchaseId);
  if (!result.success) {
    console.warn(result.message); // Delivered again at next launch
  } else if (result.data.status === Wavedash.FulfillPurchaseStatus.NOT_FOUND) {
    // Refunded before it was fulfilled: take the grant back.
    save.coins -= coins;
    await saveGame(save);
  }
});

Register the handler before calling init(), or pass deferEvents: true and call readyForEvents() once your save has loaded. Otherwise the unfulfilled purchases delivered at launch can arrive before your handler is ready. See Deferring events.

The PurchaseCompleted payload

The event fires once for each purchase made while the game is running, whatever the source. At launch, it also fires once for each consumable that's still unfulfilled.

FieldDescription
purchaseIdIdentifies this purchase. Dedupe on it, and pass it to fulfillPurchase.
contentIdentifierThe content identifier of the offer that was bought.
type"CONSUMABLE" or "DURABLE".
fulfilledfalse for a consumable that's waiting for you to fulfill it. Durables arrive true, because Wavedash already granted them.
purchasedAtWhen the purchase was made, in milliseconds since the epoch.
receiptJwtA signed receipt for this purchase, to verify on your backend.

Fulfilling a purchase

fulfillPurchase(purchaseId) marks the purchase done, so it stops being delivered. It's idempotent, so calling it more than once is safe, and so is calling it from both the game and your backend. The result's status is one of:

StatusMeaning
FULFILLEDMarked fulfilled by this call.
ALREADY_FULFILLEDAn earlier call from your game or backend got there first, or it's a durable. Treat it as success.
NOT_FOUNDThere's no such purchase for this player, or it was refunded. Don't grant it.

Each purchase is delivered at most once per session, so to retry a failed fulfillPurchase before the next launch, use getUnfulfilledPurchases() to get the consumables still waiting, oldest first.

func retry_fulfillment():
    var result = await WavedashSDK.get_unfulfilled_purchases()
    if not result.success:
        return
    for purchase in result.data:
        if save.granted_purchase_ids.has(purchase.purchaseId):
            await WavedashSDK.fulfill_purchase(purchase.purchaseId)
async void RetryFulfillment()
{
    var purchases = await Wavedash.SDK.GetUnfulfilledPurchases();
    if (purchases == null)
        return;
    foreach (var purchase in purchases)
    {
        var purchaseId = (string)purchase["purchaseId"];
        if (save.GrantedPurchaseIds.Contains(purchaseId))
            await Wavedash.SDK.FulfillPurchase(purchaseId);
    }
}
local co = coroutine.create(function()
    local result = wavedash.get_unfulfilled_purchases_async()
    if not result.success then
        return
    end
    for _, purchase in ipairs(result.data) do
        if save.granted_purchase_ids[purchase.purchaseId] then
            wavedash.fulfill_purchase_async(purchase.purchaseId)
        end
    end
end)
assert(coroutine.resume(co))
const result = await Wavedash.getUnfulfilledPurchases();
if (result.success) {
  for (const purchase of result.data) {
    if (save.grantedPurchaseIds.includes(purchase.purchaseId)) {
      await Wavedash.fulfillPurchase(purchase.purchaseId);
    }
  }
}

Testing

Consumables work in playtests and in wavedash dev. The paywall simulates the purchase without charging anyone, PURCHASE_COMPLETED fires in the SDK as it does in production, and the receipt carries env: "PLAYTEST". You can also configure the Playtest webhook URL under the Monetization tab to notify your backend of playtest purchases.