← All guides

Mock a REST API for Unity β€” UnityWebRequest, the JsonUtility array wall, and a real leaderboard

Your game needs a backend before the backend exists: a leaderboard, cloud-style save slots, a news panel on the title screen, remote config for tuning. The usual Unity answers all hurt a little:

The boring fix is a hosted mock API: a real https URL that answers the editor, a device build, and a WebGL export alike. Every C# snippet below was executed verbatim against the live endpoints on this page before publishing β€” the printed outputs are from those runs (details in the verification note). Plain coroutines, no packages, no SDK.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

2. The JsonUtility wall β€” and the server-side fix

That URL returns a top-level JSON array β€” [ {...}, {...} ] β€” and Unity's built-in JsonUtility famously cannot parse one: FromJson<T> throws ArgumentException: JSON must represent an object type. The workaround every Unity forum thread reinvents is the wrapper-string hack:

// the classic hack: wrap the array by hand, then parse the wrapper
string wrapped = "{\"items\":" + rawJson + "}";

You don't need it here. Add ?mock_envelope=items to any list URL and the server answers {"items":[...]} β€” already the shape JsonUtility wants. (Any key works: mock_envelope=data β†’ {"data":[...]}; there's a full template syntax for fancier shapes, and a project-wide default if you never want to think about it again.) So fetching and parsing is just:

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class FetchProducts : MonoBehaviour
{
    [System.Serializable] public class Product { public int id; public string name; public float price; }
    [System.Serializable] public class ProductList { public Product[] items; }

    IEnumerator Start()
    {
        // ?mock_envelope=items makes the server answer {"items":[...]} instead of a
        // bare [...] array β€” which JsonUtility can't parse. No wrapper-string hacks.
        string url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3&mock_envelope=items";
        using (UnityWebRequest req = UnityWebRequest.Get(url))
        {
            yield return req.SendWebRequest();
            ProductList list = JsonUtility.FromJson<ProductList>(req.downloadHandler.text);
            Debug.Log($"HTTP {req.responseCode} β€” got {list.items.Length} products");
            foreach (Product p in list.items)
                Debug.Log($"  #{p.id}  {p.name}  ${p.price}");
        }
    }
}

Output, from a real run:

HTTP 200 β€” got 3 products
  #1  Stone Name Day Work Bridge  $324.9
  #2  Area Week Story Way  $614.97
  #3  Line Child Month Garden World  $996.42

Prefer Newtonsoft (com.unity.nuget.newtonsoft-json)? Then top-level arrays are fine and you can skip the envelope. The rest of this page works identically β€” but staying on JsonUtility means zero packages, and the envelope trick is what makes that painless.

3. A working leaderboard in one MonoBehaviour

Two curls give you your own API with a scores collection (15 seeded rows so the screen isn't empty on first run):

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "content-type: application/json" -d '{"name":"my-game","blank":true}'
# β†’ {"id":"YOUR_ID", "adminKey":"YOUR_ADMIN_KEY", ...}  β€” save both

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/resources \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H "content-type: application/json" \
  -d '{"name":"scores","fields":[{"name":"player","type":"username"},{"name":"score","type":"number"}],"seed":15}'

Sorting is a query param, so "top 10" is just a URL β€” no server code. Writes are real: the POST persists, and the submitted score shows up in the very next GET:

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class Leaderboard : MonoBehaviour
{
    const string BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_ID";

    [System.Serializable] public class Row { public int id; public string player; public int score; }
    [System.Serializable] public class Top { public Row[] items; }
    [System.Serializable] public class NewScore { public string player; public int score; }

    IEnumerator Start()
    {
        yield return Submit("new_champion", 999999);
        using (UnityWebRequest req = UnityWebRequest.Get(BASE + "/scores?sortBy=score&order=desc&limit=10&mock_envelope=items"))
        {
            yield return req.SendWebRequest();
            Top top = JsonUtility.FromJson<Top>(req.downloadHandler.text);
            Debug.Log("=== LEADERBOARD ===");
            for (int i = 0; i < top.items.Length; i++)
                Debug.Log($"{i + 1}. {top.items[i].player} β€” {top.items[i].score}");
        }
    }

    IEnumerator Submit(string player, int score)
    {
        string json = JsonUtility.ToJson(new NewScore { player = player, score = score });
        using (UnityWebRequest req = UnityWebRequest.Post(BASE + "/scores", json, "application/json"))
        {
            yield return req.SendWebRequest();
            Debug.Log($"score submitted (HTTP {req.responseCode})");
        }
    }
}

Real output from the verification run:

score submitted (HTTP 201)
=== LEADERBOARD ===
1. new_champion β€” 999999
2. dmitricosta92 β€” 975
3. noahmensah32 β€” 763
…

Filters compose: ?score_gte=500, ?player_like=tom, ?_page=2&_limit=25 (with a real X-Total-Count header for "page 2 of 7" UI). Ship only the /m/YOUR_ID URL in the game β€” never the admin key; the key manages the project, the mock URLs don't need it.

UnityWebRequest.Post(url, json, "application/json") is the Unity 2022.2+ overload. On older versions the two-argument Post(url, string) form-encodes your JSON (a classic gotcha) β€” build the request by hand instead:

var req = new UnityWebRequest(url, "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");

4. Cloud-style save slots

Add a saves collection (empty this time β€” slots are created by the game):

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/resources \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H "content-type: application/json" \
  -d '{"name":"saves","fields":[{"name":"player","type":"username"},{"name":"level","type":"number"},{"name":"gold","type":"number"}],"seed":0}'

PUT /saves/1 overwrites slot 1 if it exists and returns 404 if it doesn't β€” so "save" is a PUT with a one-line POST fallback, and "load" is a GET (a single record comes back as a plain object, which JsonUtility parses directly β€” no envelope needed):

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class CloudSave : MonoBehaviour
{
    const string BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_ID";

    [System.Serializable] public class SaveData { public string player; public int level; public int gold; }

    IEnumerator Start()
    {
        yield return Save(1, new SaveData { player = "pilar", level = 4, gold = 250 });

        // load it back β€” it really persisted
        using (UnityWebRequest req = UnityWebRequest.Get(BASE + "/saves/1"))
        {
            yield return req.SendWebRequest();
            SaveData loaded = JsonUtility.FromJson<SaveData>(req.downloadHandler.text);
            Debug.Log($"loaded slot 1: level {loaded.level}, {loaded.gold} gold");
        }
    }

    IEnumerator Save(int slot, SaveData data)
    {
        string json = JsonUtility.ToJson(data);
        using (UnityWebRequest req = UnityWebRequest.Put(BASE + "/saves/" + slot, json))
        {
            req.SetRequestHeader("Content-Type", "application/json");
            yield return req.SendWebRequest();
            if (req.responseCode == 404) // slot doesn't exist yet β€” create it
                using (UnityWebRequest post = UnityWebRequest.Post(BASE + "/saves", json, "application/json"))
                    yield return post.SendWebRequest();
            Debug.Log($"saved slot {slot}");
        }
    }
}

Real run: saved slot 1 β†’ loaded slot 1: level 4, 250 gold. Your save data survives across sessions, machines and teammates β€” anyone on the team can inspect a bug report's save slot in a browser at /m/YOUR_ID/saves/1.

5. Test what happens when the server is slow or down

This is the part no static JSON file can do, and the reason to point your game at a mock even if the real backend exists. Add query params to any URL:

A real timeout β€” ?mock_delay=3000 holds the response for 3 s; with UnityWebRequest.timeout set shorter, your actual timeout branch runs:

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class TimeoutDrill : MonoBehaviour
{
    IEnumerator Start()
    {
        // ?mock_delay=3000 holds the response for 3s; with timeout = 1 your
        // real timeout branch runs β€” no airplane mode required.
        using (UnityWebRequest req = UnityWebRequest.Get("https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000"))
        {
            req.timeout = 1; // seconds β€” your game's real setting
            yield return req.SendWebRequest();
            if (req.result == UnityWebRequest.Result.ConnectionError)
                Debug.Log("timed out β€” show the 'server unreachable' panel, keep the game playable (" + req.error + ")");
            else
                Debug.Log($"unexpected: {req.result} HTTP {req.responseCode}");
        }
    }
}

Real run: it prints the timed-out line, with req.error == "Request timeout". A server error is one param β€” ?mock_status=500 (any code: 404, 429, 503…) β€” so the "couldn't load leaderboard" panel gets exercised before a player ever sees it by accident.

A deterministic outage for your retry loop β€” ?mock_seq=500,500,200 answers the 1st request with 500, the 2nd with 500, the 3rd normally. No flaky sleep-and-hope: the recovery is scripted.

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class RetryDrill : MonoBehaviour
{
    IEnumerator Start()
    {
        string key = Random.Range(0, int.MaxValue).ToString(); // isolate this run's sequence
        string url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1"
                   + "&mock_seq=500,500,200&mock_seq_key=" + key;

        for (int attempt = 1; attempt <= 5; attempt++)
        {
            using (UnityWebRequest req = UnityWebRequest.Get(url))
            {
                yield return req.SendWebRequest();
                Debug.Log($"attempt {attempt} β†’ HTTP {req.responseCode}");
                if (req.responseCode < 400) { Debug.Log($"recovered after {attempt} attempts"); yield break; }
            }
            yield return new WaitForSeconds(0.5f * attempt); // backoff
        }
    }
}

Real run: 500 β†’ 500 β†’ 200, recovered after 3 attempts. There's also ?mock_jitter (random latency), ?mock_chaos=0.3 (random failure fraction) and ?mock_ratelimit=5 (a real 429 with Retry-After) β€” the full simulation-params guide covers them.

6. WebGL builds (itch.io, Unity Play) actually work

In a WebGL build, UnityWebRequest is the only HTTP door β€” System.Net.Http doesn't exist there β€” and every call goes through the browser, so CORS and mixed-content rules apply: an https page on itch.io cannot call http://localhost:3000, and it can only call servers that opt in with CORS headers. Every Mockbird endpoint answers with Access-Control-Allow-Origin: * on GET, POST, PUT, PATCH and DELETE β€” so the exact leaderboard code above ships unchanged in a WebGL jam build. The build on itch.io and the editor hit the same URL.

7. Honest comparison β€” when you've outgrown a mock

JSON in Resources/Local json-serverGame backend (UGS, PlayFab, LootLocker, Nakama…)Mockbird
SetupnoneNode install + script per machineaccount, SDK/package, per-service concepts0–2 curls
Exercises real UnityWebRequest codeβœ—βœ” on one machineβœ”βœ” everywhere
Reachable from device / WebGL buildsn/aβœ— (localhost)βœ”βœ” (CORS open)
Inject timeouts / 500s / rate limitsβœ—βœ— (write middleware)βœ—one query param
Production leaderboards, auth, anti-cheatβœ—βœ—βœ” β€” this is their jobβœ— β€” see below

Be clear-eyed about the last row: a Mockbird project is a mock. Anyone with the URL can POST a 999999 score (the snippet above literally does), data is capped and resettable, and the JWT auth endpoints simulate login flows rather than secure anything. For a shipped game with real players, graduate to a real game-backend service β€” Unity Gaming Services is the first-party path, PlayFab and LootLocker are batteries-included, Nakama is open source and self-hostable. Use the mock for what it's for: building tonight, testing failure paths honestly, jam builds, and CI β€” a project takes 10,000 requests/day, which game development barely dents.

8. Useful extras

Related: mock APIs for Unreal Engine, mock APIs for Godot, mock APIs for Roblox, mock APIs for C# / .NET, testing loading and error states, mock any custom endpoint, deterministic test data with snapshots, and free mock-API tools compared.

Verification: Unity itself can't run headless on our test box, so every C# snippet on this page was executed verbatim against production on 20 Sep 2026 through a behavior-faithful .NET 8 harness β€” UnityWebRequest backed by HttpClient (same overloads, timeout β†’ ConnectionError + "Request timeout", HTTP β‰₯ 400 β†’ ProtocolError), JsonUtility reimplemented with Unity's semantics (public fields only, throws on top-level arrays, ignores unknown members), and a coroutine runner for IEnumerator Start(). The printed outputs shown are from those runs; every URL and JSON shape was additionally verified with curl. The leaderboard and save-slot sections ran against a real project created with the exact curls shown (since deleted). If a snippet doesn't work in your Unity project, that's a bug: tell us.