Search documentation

Find pages, sections, and content across all docs.

WavedashDocs

Paid content

Check what a player owns and open the paywall from your game

Paid Content lets players unlock part of your game with a one-time in-game purchase. 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.

isEntitled and getEntitlements are 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.

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. It resolves immediately if the player already owns it; otherwise it opens the modal and resolves with whether the purchase completed.

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 ENTITLEMENTS_GRANTED handler (next section), which also covers purchases your game never initiated.

func on_unlock_pressed():
    # Unlocking happens in the entitlements_granted 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 OnEntitlementsGranted, not here.
bool purchased = await Wavedash.SDK.TriggerPaywall("full-version");
if (!purchased)
    ShowPurchaseDismissedMessage();
local co = coroutine.create(function()
    -- Unlocking happens in the EntitlementsGranted 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 ENTITLEMENTS_GRANTED handler, not here.
const result = await Wavedash.triggerPaywall("full-version");
if (!result.success || !result.data) {
  showPurchaseDismissedMessage();
}

After a successful purchase, ownership refreshes automatically, so isEntitled returns true and your next request for the paid files is authorized without a reload.

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 ENTITLEMENTS_GRANTED event fires whenever the player is granted content, 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 every identifier in the payload, and requests for the paid files are authorized. The payload is a list so one checkout can grant several contents at once.

func _ready():
    WavedashSDK.entitlements_granted.connect(_on_entitlements_granted)

func _on_entitlements_granted(payload):
    for content_identifier in payload.contentIdentifiers:
        await fetch_paid_assets()
        unlock_content(content_identifier)
using Newtonsoft.Json.Linq;

void Start()
{
    Wavedash.SDK.OnEntitlementsGranted += OnEntitlementsGranted;
}

async void OnEntitlementsGranted(Dictionary<string, object> payload)
{
    var contentIdentifiers = ((JArray)payload["contentIdentifiers"]).ToObject<string[]>();
    foreach (var contentIdentifier in contentIdentifiers)
    {
        await FetchPaidAssets();
        UnlockContent(contentIdentifier);
    }
}
function init(self)
    wavedash.init({}, function(_, event, payload)
        if event == wavedash.EVENT_ENTITLEMENTS_GRANTED then
            for _, content_identifier in ipairs(payload.contentIdentifiers) do
                fetch_paid_assets(content_identifier)
                unlock_content(content_identifier)
            end
        end
    end)
end
Wavedash.on(Wavedash.Events.ENTITLEMENTS_GRANTED, async (payload) => {
  for (const contentIdentifier of payload.contentIdentifiers) {
    await fetchPaidAssets(contentIdentifier);
    unlockContent(contentIdentifier);
  }
});

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 entitlements_granted 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. OnEntitlementsGranted 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 ENTITLEMENTS_GRANTED 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 ENTITLEMENTS_GRANTED 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 OnEntitlementsGranted. 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. OnEntitlementsGranted 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 ENTITLEMENTS_GRANTED handler (plus the already-owned path on load) — so every purchase source behaves the same.

func _ready():
    WavedashSDK.entitlements_granted.connect(_on_entitlements_granted)
    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_entitlements_granted(payload):
    if "full-version" in payload.contentIdentifiers:
        await unlock_full_version()

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

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

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

async void OnEntitlementsGranted(Dictionary<string, object> payload)
{
    var contentIdentifiers = ((JArray)payload["contentIdentifiers"]).ToObject<string[]>();
    if (contentIdentifiers.Contains("full-version"))
        await UnlockFullVersion();
}

async Task UnlockFullVersion()
{
    await FetchPaidAssets();
    SetFullVersionUnlocked(true);
}
Wavedash.on(Wavedash.Events.ENTITLEMENTS_GRANTED, async (payload) => {
  if (payload.contentIdentifiers.includes("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);
}