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
- In the Developer Portal, open your game's Monetization tab and click Paid Content → Add, then choose Consumable.
- 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. - Set the Price in USD, from $0.99 to $99.99.
- Choose the Visibility: Playtest offers it only in the playtest environment while you develop, and Live offers it on your game page.
- Write the paywall's Modal copy: title, message, feature list, and button label. The preview shows what players will see.
- Click Create.
How a purchase flows
- The player buys the consumable, either from a paywall your game opens with
triggerPaywallor from your game's store page. - Wavedash tells you about it in two ways. The SDK fires a
PURCHASE_COMPLETEDevent in the running game, and, if you've set a webhook URL, Wavedash sends apurchase.completedwebhook to your backend. Use whichever event suits your game. - You apply the effect, such as adding 500 coins to the player's balance.
- 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.
- From the game. In your
PURCHASE_COMPLETEDhandler (payload), send thereceiptJwtto your backend along with the player's user JWT. This works without any webhook setup, but only while the game is running. - 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
subbelongs 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
}| Claim | What it means |
|---|---|
iss | Always https://auth.wavedash.com. |
aud | Your game's ID. Reject receipts for any other game. |
sub | The 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. |
env | PRODUCTION for real purchases, PLAYTEST for simulated ones from playtests and wavedash dev. A production backend should only grant PRODUCTION. |
gcid | The 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. |
event | purchase.completed when the purchase is made. purchase.refunded when it's refunded, sent by webhook only. Only purchase.completed receipts can fulfill a purchase. |
purchaseId | The purchase. Use it as your idempotency key, and pass it to the fulfill endpoint. |
ptype | CONSUMABLE or DURABLE. |
contentIdentifier | The content identifier of the offer, which you map to what the purchase grants. |
created | When the event happened: the purchase for purchase.completed, the refund for purchase.refunded. |
iat | When this particular receipt was signed. One purchase can have many receipts with different iat values, so don't dedupe on it. |
jti | Only 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:
- When the player launches your game, have your game send
getUserJwt()to your backend. Verify it, and look up or create the player bysub. - 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. - When the game forwards a receipt itself, also check that the receipt's
submatches the user JWT'ssub, so one player can't submit another player's receipt. Matchinggcidconfirms both tokens come from the same environment. - Store each granted
purchaseIdwith 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)
endconst 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.
| Field | Description |
|---|---|
purchaseId | Identifies this purchase. Dedupe on it, and pass it to fulfillPurchase. |
contentIdentifier | The content identifier of the offer that was bought. |
type | "CONSUMABLE" or "DURABLE". |
fulfilled | false for a consumable that's waiting for you to fulfill it. Durables arrive true, because Wavedash already granted them. |
purchasedAt | When the purchase was made, in milliseconds since the epoch. |
receiptJwt | A 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:
| Status | Meaning |
|---|---|
FULFILLED | Marked fulfilled by this call. |
ALREADY_FULFILLED | An earlier call from your game or backend got there first, or it's a durable. Treat it as success. |
NOT_FOUND | There'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.