โ† All guides

Mock a REST API for Roblox โ€” HttpService, pcall and a live leaderboard without building a backend

Your Roblox game wants data from outside Roblox: a cross-platform leaderboard your web dashboard also reads, a news panel on the title screen, remote config you can flip without republishing, analytics going out. HttpService is the tool โ€” and it has a property most HTTP tutorials skip past:

Your HTTP code runs on Roblox's game servers, not on your machine. In Studio playtests, requests happen to originate from your PC, so http://localhost:3000 can appear to work โ€” right up until you publish. The moment anyone actually plays your game, the requests come from a Roblox data center and json-server on your laptop is unreachable. Teammates and testers never could reach it. What you need from day one is a real, public https URL.

That's what a hosted mock API is for. Every Luau snippet below was executed verbatim in the open-source Luau runtime against responses captured live from the endpoints on this page (details in the verification note at the bottom) โ€” they paste straight into a server-side Script.

1. Enable HTTP requests (60 seconds, once)

GetAsync, PostAsync and RequestAsync are disabled by default. In Studio, turn on Allow HTTP Requests under File โŸฉ Game Settings โŸฉ Security (called Experience Settings in newer Studio builds). For an unpublished place you can also flip it from the command bar:

game:GetService("HttpService").HttpEnabled = true

Two rules that bite everyone once:

2. Try it in 10 seconds โ€” GetAsync + JSONDecode

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

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

And from a server Script โ€” always inside pcall, because GetAsync raises on failure (HTTP off, network trouble, or any non-2xx status, e.g. HTTP 404 (Not Found)):

local HttpService = game:GetService("HttpService")

local ok, err = pcall(function()
	local body = HttpService:GetAsync("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3")
	local products = HttpService:JSONDecode(body)
	print("got", #products, "products")
	for _, p in products do
		print(("#%d  %s โ€” $%.2f"):format(p.id, p.name, p.price))
	end
end)
if not ok then
	warn("request failed: " .. tostring(err))
end

Output, from a real run:

got  3  products
#1  World End โ€” $873.65
#2  Moment Part Moment Child Friend โ€” $114.28
#3  Place Hour Stone โ€” $132.83

Demo data reseeds daily with random names, so your product names will differ โ€” the shape (id, name, price, category, inStock, rating) is stable.

3. GetAsync throws, RequestAsync reports

The other thing worth learning on a mock rather than in production: RequestAsync does not raise on HTTP error statuses. It returns a table with Success (true iff status 200โ€“299), StatusCode, Headers and Body โ€” much nicer for real error handling. Force a 500 with one query param and see:

local HttpService = game:GetService("HttpService")

local failed = HttpService:RequestAsync({
	Url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1&mock_status=500",
	Method = "GET",
})
print(failed.Success, failed.StatusCode)  --> false 500 (no error raised)

local response = HttpService:RequestAsync({
	Url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1",
	Method = "GET",
})
-- header names arrive lowercase over HTTP/2
print("total products:", response.Headers["x-total-count"])  --> 30

That last line is a detail straight from Roblox's docs: HttpService uses HTTP/2 when available, and HTTP/2 requires lowercase header names โ€” so index Headers["x-total-count"], not Headers["X-Total-Count"]. Mockbird sends a real X-Total-Count on every list response, which is your "page 3 of 7" UI without a separate count endpoint (?_page=2&_limit=25 paginates).

4. A working external leaderboard in ~15 lines

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:

local HttpService = game:GetService("HttpService")

local BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_ID"

local function submitScore(player: string, score: number)
	HttpService:PostAsync(BASE .. "/scores", HttpService:JSONEncode({player = player, score = score}))
end

local function topScores(n: number)
	local body = HttpService:GetAsync(BASE .. "/scores?sortBy=score&order=desc&limit=" .. n)
	return HttpService:JSONDecode(body)
end

submitScore("builderman_fan", 999999)
for rank, row in topScores(5) do
	print(("%d. %s โ€” %d"):format(rank, row.player, row.score))
end

Real output from the verification run:

1. builderman_fan โ€” 999999
2. silasfernandez42 โ€” 927
3. marapetrov99 โ€” 779
4. inesmarin26 โ€” 684
5. avadubois51 โ€” 680

PostAsync sends Content-Type: application/json by default, which is exactly what the API expects โ€” no header wrangling. Filters compose in the URL: ?score_gte=500, ?player_like=fan. Ship only the /m/YOUR_ID URL in your game โ€” never the admin key; the mock URLs don't need it.

5. Drill the failure paths a static table can't fake

Retries with exponential backoff โ€” Roblox's own best-practices page tells you to back off; a deterministic failure sequence lets you actually watch your loop do it. ?mock_seq=500,500,200 answers the first request 500, the second 500, the third normally:

local HttpService = game:GetService("HttpService")

local key = HttpService:GenerateGUID(false)
local url = "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1"
	.. "&mock_seq=500,500,200&mock_seq_key=" .. key

for attempt = 1, 5 do
	local response = HttpService:RequestAsync({ Url = url, Method = "GET" })
	print("attempt", attempt, "-> HTTP", response.StatusCode)
	if response.Success then
		print("recovered after", attempt, "attempts")
		break
	end
	task.wait(2 ^ attempt)  -- exponential backoff, per Roblox's own best practices
end

Real run: 500 โ†’ 500 โ†’ 200, recovered after 3 attempts. (GenerateGUID keys the sequence to this run, so parallel servers don't share a counter.)

A real timeout branch โ€” ?mock_delay=3000 holds the response for 3 s; RequestAsync accepts a Timeout option (seconds, must be > 0 and no greater than the default), and a timed-out request raises โ€” so the pcall branch is your "servers unreachable" UI:

local HttpService = game:GetService("HttpService")

local ok = pcall(function()
	return HttpService:RequestAsync({
		Url = "https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000",
		Method = "GET",
		Timeout = 2,  -- seconds; must be > 0 and <= the default request timeout
	})
end)
if not ok then
	print("timed out โ€” show the 'servers unreachable' UI, keep the game playable")
end

Rate limits, rehearsed โ€” ?mock_ratelimit=5 gives you 5 requests, then a real 429 with a retry-after header (in seconds โ€” remember: lowercase). Since your game shares one external API across every server it's running on, the 429 path is not hypothetical; test that you honor Retry-After before players find out you don't. There's also ?mock_status=429 for a single forced failure, ?mock_jitter for random latency and ?mock_chaos=0.3 for a random failure fraction โ€” the simulation-params guide covers all of them.

6. Authorization headers and a mock login

RequestAsync takes custom headers (Roblox locks User-Agent and Roblox-Id, and derives Content-Length โ€” everything else, including Authorization, is yours). If the external API your game will eventually call needs a Bearer token, rehearse the whole flow against the mock's JWT auth endpoints:

local HttpService = game:GetService("HttpService")

local login = HttpService:JSONDecode(HttpService:PostAsync(
	"https://mockbird.mockbird.workers.dev/m/demo/auth/login",
	HttpService:JSONEncode({email = "noob@example.com", password = "hunter2"})
))

local me = HttpService:RequestAsync({
	Url = "https://mockbird.mockbird.workers.dev/m/demo/auth/me",
	Method = "GET",
	-- custom headers are fine; User-Agent and Roblox-Id are locked by Roblox
	Headers = { Authorization = "Bearer " .. login.token },
})
print(me.StatusCode, "logged in as", HttpService:JSONDecode(me.Body).user.email)  --> 200 logged in as noob@example.com

Flip your own project to protected mode and every endpoint returns a proper 401 without a valid token โ€” so the "token expired mid-session" code path gets exercised too (login accepts an expiresIn of a few seconds for exactly this).

7. Honest comparison โ€” what belongs where

ModuleScript tableLocal json-serverDataStoreService / MemoryStoreReal backend (PlayFab, Firebase, your APIโ€ฆ)Mockbird
SetupnoneNode install per machinebuilt inaccount, SDK, server code0โ€“2 curls
Exercises real HttpService codeโœ—only in Studio, only on your PCn/a (not HTTP)โœ”โœ” โ€” Studio and published servers
Reachable from published game serversn/aโœ— (localhost)โœ”โœ”โœ”
Inject timeouts / 500s / 429sโœ—โœ— (write middleware)โœ—โœ—one query param
Player save data in productionโœ—โœ—โœ” โ€” this is its jobpossible, but DataStore is simplerโœ— โ€” see below

Be clear-eyed about the boundaries. Per-player save data belongs in DataStoreService โ€” it's built in, free, and doesn't count against your HTTP budget; don't route coins-and-inventory through an external API at all. And 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. Use it for what it's for: building the external integration tonight, testing failure paths honestly, and standing in for the real API โ€” then graduate the endpoint URL to your production backend. A project takes 10,000 requests/day; at Roblox's own recommended polling rates that's plenty for development.

8. Useful extras

Related: mock APIs for Unreal Engine, mock APIs for Unity, mock APIs for Godot, testing loading and error states, mock any custom endpoint, mock JWT auth, and free mock-API tools compared.

Verification: every URL on this page was exercised against production on 22 Sep 2026, and every Luau snippet was then executed verbatim in the open-source Luau 0.739 runtime against an HttpService shim replaying those live-captured responses byte-for-byte (statuses, bodies, headers) โ€” standalone Luau has no network access, and Roblox's engine only runs inside Roblox. The shim reproduces documented semantics: GetAsync/PostAsync raise on non-2xx, RequestAsync returns Success/StatusCode/Headers/Body. The printed outputs shown are from those runs; the leaderboard section ran against a real project created with the exact curls shown (since deleted). Platform facts (enable toggle, server-side origin, 500 req/min, locked headers, lowercase HTTP/2 header names, Timeout option) are from Roblox's official creator docs, read the same day. If a snippet fails in an actual Script with HTTP enabled, that's a bug: tell us.