# Durables

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

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

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](/publishing/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](/sdk/paid-content/consumables).

<Note>
`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](#fetching-the-paid-files).
</Note>

## 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](/publishing/monetization#paid-assets) 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.

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

```csharp Unity
bool owned = await Wavedash.SDK.IsEntitled("full-version");
if (owned)
{
    await FetchPaidAssets();
    UnlockFullVersion();
}
```

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

```javascript JavaScript
const result = await Wavedash.isEntitled("full-version");
if (result.success && result.data) {
  await fetchPaidAssets();
  unlockFullVersion();
}
```
</CodeGroup>

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

<CodeGroup>
```gdscript Godot
func load_entitlements():
    var result = await WavedashSDK.get_entitlements()
    if result.success:
        for id in result.data:
            print("Owns: ", id)
```

```csharp Unity
List<string> owned = await Wavedash.SDK.GetEntitlements();
foreach (var id in owned)
    Debug.Log($"Owns: {id}");
```

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

```javascript JavaScript
const result = await Wavedash.getEntitlements();
if (result.success) {
  for (const id of result.data) console.log("Owns:", id);
}
```
</CodeGroup>

## 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](/sdk/paid-content/consumables#opening-the-paywall).)

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.

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

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

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

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

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](/sdk/paid-content/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.

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

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

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

```javascript JavaScript
Wavedash.on(Wavedash.Events.PURCHASE_COMPLETED, async (purchase) => {
  if (purchase.type !== Wavedash.PurchaseType.DURABLE) return;
  await fetchPaidAssets(purchase.contentIdentifier);
  unlockContent(purchase.contentIdentifier);
});
```
</CodeGroup>

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

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

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

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

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

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.

<IfLang lang="gdscript" default>
**Godot specifics.** `download_content` saves to `user://` + `item_path`; pass a second argument to save it somewhere else:

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

Alongside the usual `success` and `message`, the response carries:

| Field | Description |
|-------|-------------|
| `data` | Where the file was saved. Feed it to `ProjectSettings.load_resource_pack()` for a `.pck`, or to `FileAccess` / `Image.load_from_file()` for loose assets. |
| `code` | The HTTP status, or `0` if the request never left the client. |
| `content_identifiers` | On `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.

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

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

```gdscript Godot
func _ready():
    WavedashSDK.content_downloaded.connect(func(r): print("Saved to: ", r.data))
```
</IfLang>

<IfLang lang="csharp">
**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`:

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

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

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

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

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