Search documentation

Find pages, sections, and content across all docs.

WavedashDocs

Durables

One-time purchases that gate build files: check ownership, open the paywall, and load the paid files

A durable is purchased once and never expires (ie a base game, downloadable content, a soundtrack). It gates files in your build, and Wavedash fulfills the purchase for you: once the player buys it, their permission to fetch those files is updated and isEntitled returns true. You define the locked files, price, and paywall appearance in the Developer Portal (see Monetization); the SDK lets your game check ownership and open the paywall, keyed by the content identifier you set on each offer.

Selling something the player can buy again and again, like in-game currency, extra lives, or extra time? That's a consumable.

isEntitled and getEntitlements only cover durables; a consumable is never "owned", so it's always false and never listed. They're UI hints; use them to decide what to show, not as the lock itself. Wavedash re-checks ownership when it serves the paid files, so locked content stays protected even if a client-side check is bypassed — see Fetching the paid files.

Creating a durable

  1. In the Developer Portal, open your game's Monetization tab and click Paid Content → Add, then choose Durable.
  2. Set a Content identifier, like full-version. Your game passes it to the SDK. It's up to 32 letters, numbers, hyphens, or underscores, and can't be changed later.
  3. Under Paid assets, list the build files to lock as glob patterns, like **/full-version/**. Wavedash won't serve them until the player owns the durable. See Monetization for the pattern syntax.
  4. Set the Price in USD, from $0.99 to $99.99.
  5. Choose the Visibility: Playtest offers it only in the playtest environment while you develop, and Live offers it on your game page.
  6. Write the paywall's Modal copy: title, message, feature list, and button label. The preview shows what players will see.
  7. Click Create.

Checking entitlement

isEntitled returns whether the player already owns a content identifier; use it to unlock content on load or to decide whether to show the paywall.

func check_full_version():
    var result = await WavedashSDK.is_entitled("full-version")
    if result.success and result.data:
        await fetch_paid_assets()
        unlock_full_version()
bool owned = await Wavedash.SDK.IsEntitled("full-version");
if (owned)
{
    await FetchPaidAssets();
    UnlockFullVersion();
}
local co = coroutine.create(function()
    local result = wavedash.is_entitled_async("full-version")
    if result.success and result.data then
        fetch_paid_assets()
        unlock_full_version()
    end
end)
assert(coroutine.resume(co))
const result = await Wavedash.isEntitled("full-version");
if (result.success && result.data) {
  await fetchPaidAssets();
  unlockFullVersion();
}

Listing everything a player owns

getEntitlements returns every content identifier the player owns for your game, so you can gate several items in one call.

func load_entitlements():
    var result = await WavedashSDK.get_entitlements()
    if result.success:
        for id in result.data:
            print("Owns: ", id)
List<string> owned = await Wavedash.SDK.GetEntitlements();
foreach (var id in owned)
    Debug.Log($"Owns: {id}");
local co = coroutine.create(function()
    local result = wavedash.get_entitlements_async()
    if result.success then
        for _, id in ipairs(result.data) do
            print("Owns:", id)
        end
    end
end)
assert(coroutine.resume(co))
const result = await Wavedash.getEntitlements();
if (result.success) {
  for (const id of result.data) console.log("Owns:", id);
}

Opening the paywall

triggerPaywall opens the Wavedash-rendered checkout for a content identifier. For a durable the player already owns, it resolves true immediately without opening anything, so your game can call it freely. Otherwise it opens the modal and resolves with whether the purchase completed. (Consumables never short-circuit; see Consumables.)

Use the result for flow control only — resuming what the player was doing, or reacting to a dismissed paywall. The unlocking itself belongs in your PURCHASE_COMPLETED handler (next section), which also covers purchases your game never initiated.

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

Don't fetch the paid files when triggerPaywall resolves. PURCHASE_COMPLETED fires for the same purchase, after ownership has refreshed, so isEntitled already returns true and requests for the paid files are authorized without a reload. Fetching there covers every purchase path in one place.

Godot signal alternative. If you prefer signals over await, these calls also emit got_is_entitled, got_entitlements, and paywall_resolved when their response arrives.

Reacting to purchases from anywhere

Players can also buy your content without going through your paywall — from the unlockable content list on your game's store page, or by redeeming a gift code — including while your game is running. The PURCHASE_COMPLETED event fires once per purchase, whatever the source, and it also fires for purchases made through your own triggerPaywall call.

That makes the handler the one place to fetch the paid files and unlock content, for every purchase path at once. Ownership is refreshed before the event fires: isEntitled already returns true for the payload's contentIdentifier, and requests for the paid files are authorized.

The same event carries consumables, so check type. Durables arrive with fulfilled: true — Wavedash already granted them, so there's nothing to call back. They're only delivered for purchases made while the game is running; for anything bought earlier, the isEntitled check on load covers it.

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

func _on_purchase_completed(purchase):
    if purchase.type != WavedashSDK.Constants.PURCHASE_TYPE_DURABLE:
        return
    await fetch_paid_assets()
    unlock_content(purchase.contentIdentifier)
void Start()
{
    Wavedash.SDK.OnPurchaseCompleted += OnPurchaseCompleted;
}

async void OnPurchaseCompleted(Dictionary<string, object> purchase)
{
    if ((string)purchase["type"] != WavedashConstants.PurchaseType.DURABLE)
        return;
    await FetchPaidAssets();
    UnlockContent((string)purchase["contentIdentifier"]);
}
function init(self)
    wavedash.init({}, function(_, event, purchase)
        if event == wavedash.EVENT_PURCHASE_COMPLETED
            and purchase.type == wavedash.PURCHASE_TYPE_DURABLE then
            fetch_paid_assets(purchase.contentIdentifier)
            unlock_content(purchase.contentIdentifier)
        end
    end)
end
Wavedash.on(Wavedash.Events.PURCHASE_COMPLETED, async (purchase) => {
  if (purchase.type !== Wavedash.PurchaseType.DURABLE) return;
  await fetchPaidAssets(purchase.contentIdentifier);
  unlockContent(purchase.contentIdentifier);
});

ENTITLEMENTS_GRANTED is deprecated in favor of PURCHASE_COMPLETED. It still fires for durables, so existing handlers keep working, but new code should listen for PURCHASE_COMPLETED.

Fetching the paid files

The paid files ship inside your build, but Wavedash doesn't serve them until the player owns the content — that request is the real lock. Every request for a build file is checked against your offer's glob patterns, and a locked one comes back HTTP 403 with a JSON body listing the contentIdentifiers the player is missing, which is exactly what you need to open the right paywall.

The examples above call a fetch_paid_assets() helper. How you write it depends on the engine: Godot ships the fetch as download_content, Unity normally lets Addressables or an AssetBundle make the request for you, and in JavaScript you fetch the file yourself.

func fetch_paid_assets() -> bool:
    var result = await WavedashSDK.download_content("full-version/full.pck")

    if result.code == 403 and not result.content_identifiers.is_empty():
        # Locked — offer the purchase. The purchase_completed handler
        # calls this again once the content is owned.
        WavedashSDK.trigger_paywall(result.content_identifiers[0])
        return false

    if not result.success:
        push_error(result.message)
        return false

    return ProjectSettings.load_resource_pack(result.data)
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

// Addressables fetches the bundle itself, and Wavedash gates that request like
// any other build file — so settle ownership first, then load.
async void LoadFullVersion()
{
    if (!await Wavedash.SDK.IsEntitled("full-version"))
    {
        // Locked — offer the purchase. OnPurchaseCompleted calls this
        // again once the content is owned.
        await Wavedash.SDK.TriggerPaywall("full-version");
        return;
    }

    var handle = Addressables.LoadAssetAsync<GameObject>("FullVersionLevel");
    var level = await handle.Task;

    if (handle.Status != AsyncOperationStatus.Succeeded)
    {
        Debug.LogError($"Load failed: {handle.OperationException?.Message}");
        return;
    }

    Instantiate(level);
}
async function fetchPaidAssets(itemPath) {
  const res = await fetch(`/${itemPath}`);

  if (res.status === 403) {
    // Locked — the body lists what the player is missing, so offer that.
    // The PURCHASE_COMPLETED handler calls this again once it's owned.
    const { contentIdentifiers } = await res.json();
    await Wavedash.triggerPaywall(contentIdentifiers[0]);
    return null;
  }

  if (!res.ok) throw new Error(`${itemPath}: HTTP ${res.status}`);
  return new Uint8Array(await res.arrayBuffer());
}

Four things hold whichever engine you're in:

  • Ask for the path your globs match. That's the file's path from your build root — full-version/full.pck — not an engine resource path like res://… or Assets/….
  • Don't attach credentials. The player's session rides along as an httpOnly cookie, so a plain request from your own build is already authenticated. There's no token to add, and nothing for your game code to read.
  • Retry, don't reload. Ownership refreshes before PURCHASE_COMPLETED fires, and the 403 is sent no-store, so re-requesting the same URL from your handler succeeds in the same session.
  • Loads your engine starts on its own count too. An Addressables bundle, a streamed audio clip, a texture a scene references — each one is a build-file request, so each is gated the same way. That's what lets Unity's normal loading path work untouched, and it's also why anything your free portion needs must sit outside the locked patterns.

Godot specifics. download_content saves to user:// + item_path; pass a second argument to save it somewhere else:

await WavedashSDK.download_content("full-version/full.pck", "user://dlc/full.pck")

Alongside the usual success and message, the response carries:

FieldDescription
dataWhere the file was saved. Feed it to ProjectSettings.load_resource_pack() for a .pck, or to FileAccess / Image.load_from_file() for loose assets.
codeThe HTTP status, or 0 if the request never left the client.
content_identifiersOn 403, the identifiers the player must own. Pass one to trigger_paywall.

Nothing about this is paywall-specific: it also works for content you simply kept out of the initial download, like extra levels or high-resolution texture packs.

download_content runs in Web builds only — in the editor it resolves with success: false and a message saying so.

Signal alternative. The call also emits content_downloaded with the same response, if you'd rather not await it:

func _ready():
    WavedashSDK.content_downloaded.connect(func(r): print("Saved to: ", r.data))

Unity specifics. Addressables surfaces a locked bundle as a failed operation, and the 403 response body never reaches your code — there's nothing to read contentIdentifiers out of. That's why the example checks IsEntitled before loading instead of reacting to the failure, opens the paywall when the player doesn't own it, and leaves the post-purchase load to OnPurchaseCompleted. The server-side check still stands behind it; the entitlement call is just what lets you show a paywall instead of a load error.

Lock the content by folder rather than by bundle filename. Addressables can append a content hash to bundle names, so a pattern written against an exact filename stops matching after a rebuild — give the paid group its own build path and glob **/full-version/**.

For a loose file that isn't packed into a bundle — a video, a level blob, a texture pack — request it yourself, and you do get the identifiers back on a 403:

using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

[Serializable]
class LockedContent { public string[] contentIdentifiers; }

// Build files are served from the origin the game runs on.
static string ContentUrl(string itemPath) =>
    new Uri(new Uri(Application.absoluteURL), "/" + itemPath.TrimStart('/')).ToString();

// Unity 6 lets you await a UnityWebRequest directly.
async Task<byte[]> FetchPaidAssets(string itemPath = "full-version/full.bundle")
{
    using (var request = UnityWebRequest.Get(ContentUrl(itemPath)))
    {
        await request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
            return request.downloadHandler.data; // e.g. AssetBundle.LoadFromMemory(bytes)

        if (request.responseCode == 403)
        {
            // Locked — the body lists what the player is missing, so offer
            // that. OnPurchaseCompleted re-runs this fetch once it's owned.
            var locked = JsonUtility.FromJson<LockedContent>(request.downloadHandler.text);
            _ = Wavedash.SDK.TriggerPaywall(locked.contentIdentifiers[0]);
            return null;
        }

        Debug.LogError($"{itemPath}: {request.error}");
        return null;
    }
}

Example: gating the full version

Check ownership on load, and open the paywall when the player taps the locked content. Fetching the paid files and updating the UI happens in exactly one place — the PURCHASE_COMPLETED handler (plus the already-owned path on load) — so every purchase source behaves the same.

func _ready():
    WavedashSDK.purchase_completed.connect(_on_purchase_completed)
    var result = await WavedashSDK.is_entitled("full-version")
    if result.success and result.data:
        await unlock_full_version()

func on_locked_track_pressed():
    WavedashSDK.trigger_paywall("full-version")

func _on_purchase_completed(purchase):
    if purchase.contentIdentifier == "full-version":
        await unlock_full_version()

func unlock_full_version():
    await fetch_paid_assets()
    set_full_version_unlocked(true)
using System.Collections.Generic;
using System.Threading.Tasks;

async void Start()
{
    Wavedash.SDK.OnPurchaseCompleted += OnPurchaseCompleted;
    if (await Wavedash.SDK.IsEntitled("full-version"))
        await UnlockFullVersion();
}

public void OnLockedTrackPressed()
{
    _ = Wavedash.SDK.TriggerPaywall("full-version");
}

async void OnPurchaseCompleted(Dictionary<string, object> purchase)
{
    if ((string)purchase["contentIdentifier"] == "full-version")
        await UnlockFullVersion();
}

async Task UnlockFullVersion()
{
    await FetchPaidAssets();
    SetFullVersionUnlocked(true);
}
Wavedash.on(Wavedash.Events.PURCHASE_COMPLETED, async (purchase) => {
  if (purchase.contentIdentifier === "full-version") {
    await unlockFullVersion();
  }
});

const owned = await Wavedash.isEntitled("full-version");
if (owned.success && owned.data) await unlockFullVersion();

async function onLockedTrackPressed() {
  await Wavedash.triggerPaywall("full-version");
}

async function unlockFullVersion() {
  await fetchPaidAssets();
  setFullVersionUnlocked(true);
}