Your Unreal project wants data from a web service: a cross-platform leaderboard, a message-of-the-day on the title screen, remote config you can flip without re-cooking, a stub for the studio backend that doesn't exist yet. The engine ships everything you need to call an API โ FHttpModule in C++, an HTTP Blueprint node for visual scripting โ but you still need an API to call.
json-server on localhost works right up until it doesn't: the packaged build you hand a playtester runs on their machine, the Android/iOS device build has no idea what your localhost:3000 means, your teammates' editors can't reach your laptop, and neither can CI. What you want from day one is a real, public https URL โ which also keeps iOS App Transport Security happy.
That's what a hosted mock API is for. Every C++ snippet below was compiled and executed verbatim against live production responses โ the printed log lines are from those runs (details in the verification note at the bottom).
In YourProject.Build.cs, add the HTTP and Json modules:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore",
"HTTP", "Json" }); // add "JsonUtilities" too if you want FJsonObjectConverter โ USTRUCTs
And in the file that makes requests:
#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
One fact that makes life easy: completion delegates fire on the game thread (the HTTP thread does the transfer, then the callback is marshalled back), so it's safe to touch actors, widgets and UObjects directly in the handler โ no manual synchronization.
A shared, self-resetting demo project is live right now. In a terminal:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
And the same request from C++, with the three checks every real handler needs โ connection, status, parse. Note bConnectedSuccessfully: it is false only for transport failures (no network, DNS, timeout). An HTTP 500 still "connects", so check the status code separately:
void FetchProducts()
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(TEXT("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"));
Request->SetVerb(TEXT("GET"));
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("no connection - show the offline UI"));
return;
}
if (!EHttpResponseCodes::IsOk(Res->GetResponseCode()))
{
UE_LOG(LogTemp, Warning, TEXT("HTTP %d"), Res->GetResponseCode());
return;
}
TArray<TSharedPtr<FJsonValue>> Products;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Res->GetContentAsString());
if (FJsonSerializer::Deserialize(Reader, Products))
{
UE_LOG(LogTemp, Log, TEXT("got %d products (of %s total)"),
Products.Num(), *Res->GetHeader(TEXT("x-total-count")));
for (const TSharedPtr<FJsonValue>& Item : Products)
{
TSharedPtr<FJsonObject> P = Item->AsObject();
UE_LOG(LogTemp, Log, TEXT("#%d %s - $%.2f"),
P->GetIntegerField(TEXT("id")),
*P->GetStringField(TEXT("name")),
P->GetNumberField(TEXT("price")));
}
}
});
Request->ProcessRequest();
}
Log output, from a real run:
LogTemp: got 3 products (of 30 total)
LogTemp: #1 World End - $873.65
LogTemp: #2 Moment Part Moment Child Friend - $114.28
LogTemp: #3 Place Hour Stone - $132.83
Demo data reseeds daily with random names, so yours will differ โ the shape (id, name, price, category, inStock, rating) is stable. That x-total-count header rides on every list response (lowercase โ HTTP/2 requires lowercase header names) and is your "page 3 of 7" UI without a separate count endpoint; ?_page=2&_limit=25 paginates. Prefer typed structs over FJsonObject spelunking? Add JsonUtilities and use FJsonObjectConverter::JsonArrayStringToUStruct โ field matching is case-insensitive, so player/score map straight onto FString Player; int32 Score;.
Since 5.4 the engine ships an HTTP Blueprint plugin (Edit โฉ Plugins โ it's marked Experimental): enable it and you get Http Request nodes (GET/POST/PUTโฆ verb dropdown, URL, headers and body pins) callable from any graph. Pair it with the built-in Json Blueprint Utilities plugin (Load Json from String, Get Field) to pull values out of the response without touching C++. Point them at the demo URLs on this page โ they're plain GETs and POSTs, nothing engine-specific.
If Experimental plugins make you nervous in a shipping project, VaRest is the long-standing free community plugin for exactly this (full JSON object nodes, request callbacks), and the same URLs work unchanged.
Two curls create 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}'
Writes persist โ this is not a JSONPlaceholder-style fake, you can GET your POST back โ and "top 10" is just a URL:
static const FString BASE = TEXT("https://mockbird.mockbird.workers.dev/m/YOUR_ID");
void SubmitScore(const FString& Player, int32 Score)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(BASE + TEXT("/scores"));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Request->SetContentAsString(FString::Printf(
TEXT("{\"player\":\"%s\",\"score\":%d}"), *Player, Score));
Request->ProcessRequest();
}
void ShowTopScores(int32 N)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(FString::Printf(
TEXT("%s/scores?sortBy=score&order=desc&limit=%d"), *BASE, N));
Request->SetVerb(TEXT("GET"));
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid() ||
!EHttpResponseCodes::IsOk(Res->GetResponseCode()))
{
return; // leave the previous board on screen
}
TArray<TSharedPtr<FJsonValue>> Rows;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Res->GetContentAsString());
if (FJsonSerializer::Deserialize(Reader, Rows))
{
int32 Rank = 1;
for (const TSharedPtr<FJsonValue>& Row : Rows)
{
TSharedPtr<FJsonObject> R = Row->AsObject();
UE_LOG(LogTemp, Log, TEXT("%d. %s - %d"), Rank++,
*R->GetStringField(TEXT("player")),
R->GetIntegerField(TEXT("score")));
}
}
});
Request->ProcessRequest();
}
Real log output from the verification run (SubmitScore(TEXT("gordon_freeman"), 999999) first, then ShowTopScores(5)):
LogTemp: 1. gordon_freeman - 999999
LogTemp: 2. oliviaali76 - 861
LogTemp: 3. avajackson74 - 831
LogTemp: 4. mateoosei94 - 689
LogTemp: 5. sophialopez92 - 672
Filters compose in the URL: ?score_gte=500, ?player_like=free, ?_page=2&_limit=25. Ship only the /m/YOUR_ID URL in your game โ never the admin key; the mock endpoints don't need it.
Retries with exponential backoff โ a deterministic failure sequence lets you actually watch your retry loop work. ?mock_seq=500,500,200 answers the first request 500, the second 500, the third normally:
void FetchWithBackoff(const FString& Url, int32 Attempt)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(Url);
Request->SetVerb(TEXT("GET"));
Request->OnProcessRequestComplete().BindLambda(
[Url, Attempt](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
const int32 Code = (bConnectedSuccessfully && Res.IsValid()) ? Res->GetResponseCode() : 0;
UE_LOG(LogTemp, Log, TEXT("attempt %d -> HTTP %d"), Attempt, Code);
if (EHttpResponseCodes::IsOk(Code))
{
UE_LOG(LogTemp, Log, TEXT("recovered after %d attempts"), Attempt);
return;
}
if (Attempt >= 5)
{
UE_LOG(LogTemp, Warning, TEXT("still failing - show the outage UI"));
return;
}
const float Delay = FMath::Pow(2.0f, Attempt); // 2s, 4s, 8s...
// In an Actor you'd schedule this with a timer instead of blocking:
// FTimerHandle H; GetWorldTimerManager().SetTimer(H,
// [Url, Attempt] { FetchWithBackoff(Url, Attempt + 1); }, Delay, false);
FPlatformProcess::Sleep(Delay);
FetchWithBackoff(Url, Attempt + 1);
});
Request->ProcessRequest();
}
void DrillBackoff()
{
// GUID-keyed so parallel test runs don't share the sequence counter
const FString Url = TEXT("https://mockbird.mockbird.workers.dev/m/demo/products?limit=1")
TEXT("&mock_seq=500,500,200&mock_seq_key=") + FGuid::NewGuid().ToString();
FetchWithBackoff(Url, 1);
}
Real run:
LogTemp: attempt 1 -> HTTP 500
LogTemp: attempt 2 -> HTTP 500
LogTemp: attempt 3 -> HTTP 200
LogTemp: recovered after 3 attempts
A real timeout branch โ ?mock_delay=3000 holds the response for 3 s; SetTimeout caps this one request (there's also SetActivityTimeout for stalled transfers). A timed-out request comes back with bConnectedSuccessfully == false โ the same branch as airplane mode, so one code path covers both:
void DrillTimeout()
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
// server holds the response for 3 s; we give up after 2
Request->SetURL(TEXT("https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000"));
Request->SetVerb(TEXT("GET"));
Request->SetTimeout(2.0f); // seconds, this request only
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid())
{
UE_LOG(LogTemp, Warning,
TEXT("timed out - show 'servers unreachable', keep the game playable"));
return;
}
UE_LOG(LogTemp, Log, TEXT("HTTP %d"), Res->GetResponseCode());
});
Request->ProcessRequest();
}
Rate limits, rehearsed โ ?mock_ratelimit=5 allows 5 requests per 60-second window, then answers a real 429 with a retry-after header. Every response carries x-ratelimit-remaining, so you can render the countdown UI too:
void DrillRateLimit()
{
// key the window to this run so parallel drills don't share a counter
const FString Url = TEXT("https://mockbird.mockbird.workers.dev/m/demo/products?limit=1")
TEXT("&mock_ratelimit=5&mock_ratelimit_key=") + FGuid::NewGuid().ToString();
for (int32 i = 1; i <= 6; ++i)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(Url);
Request->SetVerb(TEXT("GET"));
Request->OnProcessRequestComplete().BindLambda(
[i](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid()) { return; }
if (Res->GetResponseCode() == 429)
{
// header names arrive lowercase over HTTP/2
UE_LOG(LogTemp, Warning, TEXT("request %d: 429 - honor retry-after: %s s"),
i, *Res->GetHeader(TEXT("retry-after")));
}
else
{
UE_LOG(LogTemp, Log, TEXT("request %d: HTTP %d (%s of %s left)"), i,
Res->GetResponseCode(),
*Res->GetHeader(TEXT("x-ratelimit-remaining")),
*Res->GetHeader(TEXT("x-ratelimit-limit")));
}
});
Request->ProcessRequest();
}
}
Real run:
LogTemp: request 1: HTTP 200 (4 of 5 left)
LogTemp: request 2: HTTP 200 (3 of 5 left)
LogTemp: request 3: HTTP 200 (2 of 5 left)
LogTemp: request 4: HTTP 200 (1 of 5 left)
LogTemp: request 5: HTTP 200 (0 of 5 left)
LogTemp: Warning: request 6: 429 - honor retry-after: 30 s
There's also ?mock_status=503 for a single forced failure, ?mock_jitter=100-1500 for random latency and ?mock_chaos=0.3 to fail a random fraction of requests โ the simulation-params guide covers all of them.
If the API your game will eventually call needs a Bearer token, rehearse the whole flow โ login, store token, send it, handle expiry โ against the mock's JWT auth endpoints. Any email/password pair works on the demo:
void FetchProfile(const FString& Token)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(TEXT("https://mockbird.mockbird.workers.dev/m/demo/auth/me"));
Request->SetVerb(TEXT("GET"));
Request->SetHeader(TEXT("Authorization"), TEXT("Bearer ") + Token);
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid()) { return; }
TSharedPtr<FJsonObject> Json;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Res->GetContentAsString());
if (FJsonSerializer::Deserialize(Reader, Json) && Res->GetResponseCode() == 200)
{
UE_LOG(LogTemp, Log, TEXT("HTTP %d - logged in as %s"), Res->GetResponseCode(),
*Json->GetObjectField(TEXT("user"))->GetStringField(TEXT("email")));
}
});
Request->ProcessRequest();
}
void Login()
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(TEXT("https://mockbird.mockbird.workers.dev/m/demo/auth/login"));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Request->SetContentAsString(TEXT("{\"email\":\"gordon@example.com\",\"password\":\"crowbar\"}"));
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bConnectedSuccessfully)
{
if (!bConnectedSuccessfully || !Res.IsValid() || Res->GetResponseCode() != 200) { return; }
TSharedPtr<FJsonObject> Json;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Res->GetContentAsString());
if (FJsonSerializer::Deserialize(Reader, Json))
{
FetchProfile(Json->GetStringField(TEXT("token")));
}
});
Request->ProcessRequest();
}
Real run: LogTemp: HTTP 200 - logged in as gordon@example.com. Flip your own project to protected mode and every endpoint returns a proper 401 without a valid token โ and since login accepts an expiresIn of a few seconds, the "token expired mid-session" path gets exercised too.
| DataTable / hardcoded struct | Local json-server | Epic Online Services | Real backend (PlayFab, your APIโฆ) | Mockbird | |
|---|---|---|---|---|---|
| Setup | none | Node install per machine | account, SDK, portal config | account, SDK, server code | 0โ2 curls |
| Exercises real FHttpModule code | โ | โ on your PC only | n/a (own SDK) | โ | โ โ editor, packaged and device builds |
| Reachable from teammates / playtesters / CI | n/a | โ (localhost) | โ | โ | โ |
| Inject timeouts / 500s / 429s | โ | โ (write middleware) | โ | โ | one query param |
| Production leaderboards / stats / player auth | โ | โ | โ โ this is its job, free | โ | โ โ see below |
Be clear-eyed about the boundaries. 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 endpoints simulate auth rather than secure anything. When your game ships, real leaderboards, achievements, stats and player auth belong in Epic Online Services (free, engine-integrated) or your studio's backend. Use the mock for what it's for: building the HTTP integration tonight, drilling the failure paths honestly, and standing in for the real API until it exists โ then change one base URL. A project takes 10,000 requests/day, which is plenty for development and CI.
{"preset":"ecommerce"} (or blog/saas) instead of blank:true creates a multi-resource seeded API in one curl โ or one click.GET /config/event-weekend for the double-XP toggle, edited from curl while the build is in testers' hands.posts resource + ?sortBy=date&order=desc&limit=5 is the whole panel.?mock_snapshot=name), so functional tests don't flake when someone posts a new high score.Related: mock APIs for Unity, mock APIs for Godot, mock APIs for Roblox, testing loading and error states, mock any custom endpoint, mock JWT auth, and free mock-API tools compared.
Verification: Unreal Engine itself can't run on our test box, so every C++ snippet on this page was compiled verbatim (g++, C++17) against a behavior-faithful shim of the engine's HTTP and Json APIs โ FHttpModule/IHttpRequest/IHttpResponse with documented completion semantics (bConnectedSuccessfully=false + null response on timeout/transport failure; HTTP 4xx/5xx still "connected"), FJsonSerializer/FJsonObject/FJsonValue with the documented API, case-insensitive GetHeader โ and executed against production on 22 Sep 2026 with real network I/O. The log lines shown are from those runs; every URL was additionally verified with curl. The leaderboard section ran against a real project created with the exact curls shown (since deleted). Engine facts (Build.cs modules, game-thread delegates, SetTimeout/SetActivityTimeout, the Experimental status of the HTTP Blueprint and Json Blueprint Utilities plugins) are from Epic's official documentation, read the same day. If a snippet fails in your project, that's a bug: tell us.