# Consumables

Purchases that deplete during gameplay and can be purchased again

Source: https://docs.wavedash.com/sdk/paid-content/consumables

A consumable is a purchase the player can make again and again (ie in-game currency, extra lives, extra time). Unlike a [durable](/sdk/paid-content/durables), 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](/api/webhooks) 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](/api/purchases) 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 `purchaseId`s you've granted, and grant each one only once.

<Note>
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`.
</Note>

## 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.

<CodeGroup>
```gdscript Godot
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()
```

```csharp Unity
// Granting happens in OnPurchaseCompleted, not here.
bool purchased = await Wavedash.SDK.TriggerPaywall("coins-500");
if (!purchased)
    ShowPurchaseDismissedMessage();
```

```lua Defold
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))
```

```javascript JavaScript
// Granting happens in the PURCHASE_COMPLETED handler, not here.
const result = await Wavedash.triggerPaywall("coins-500");
if (!result.success || !result.data) showPurchaseDismissedMessage();
```
</CodeGroup>

## 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](#the-purchasecompleted-payload)), send the `receiptJwt` to your backend along with the player's [user JWT](/sdk/players#verifying-the-jwt-on-your-backend). 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](#matching-purchases-to-players).

### Forwarding purchases from the game

```javascript
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](https://expressjs.com) and [`jose`](https://github.com/panva/jose). See [UserJwtPayload](/sdk/types#userjwtpayload) and [PurchaseReceiptPayload](/sdk/types#purchasereceiptpayload) for every claim in the two tokens.

```typescript
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](/api/purchases) 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](/api/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](/sdk/players#verifying-the-jwt-on-your-backend), published at [`https://auth.wavedash.com/.well-known/jwks.json`](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`.

```typescript
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](/sdk/types#purchasereceiptpayload)):

```typescript
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](/sdk/players#verifying-the-jwt-on-your-backend) 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](/sdk/players#verifying-the-jwt-on-your-backend), 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](/api/webhooks) 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 `purchaseId`s, 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.

<CodeGroup>
```gdscript Godot
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()
```

```csharp Unity
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();
    }
}
```

```lua Defold
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
```

```javascript JavaScript
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);
  }
});
```
</CodeGroup>

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](/sdk/events#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](#verifying-the-receiptjwt). |

### 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.

<CodeGroup>
```gdscript Godot
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)
```

```csharp Unity
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);
    }
}
```

```lua Defold
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))
```

```javascript JavaScript
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);
    }
  }
}
```
</CodeGroup>

## 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.
