Web Services in Godot 4.3+
Talk to REST APIs, leaderboards, analytics, login services, and other HTTP/WebSocket backends. All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
Related skills: multiplayer-basics for real-time game networking with WebSocketMultiplayerPeer, save-load for persisting tokens and cached responses, web-export for CORS and browser limits, mobile-development for Android export permissions, multithreading for heavy response parsing off the main thread.
1. Picking the Tool
| Need | Use | Why |
|---|
| Ordinary REST call, file or image download | HTTPRequest node | Handles connect, redirects, gzip, timeout; emits one signal when done |
| Streaming a response, custom keep-alive, many requests to one host | HTTPClient | Low-level; you call poll() and drive the state machine yourself |
| Persistent two-way channel (chat, live events, lobby service) | WebSocketPeer | Client socket; call poll() every frame |
| Real-time gameplay sync with RPCs | WebSocketMultiplayerPeer | Plugs into MultiplayerAPI — see multiplayer-basics |
One HTTPRequest node runs one request at a time. A second request() while one is in flight returns ERR_BUSY. Create one node per concurrent request (Section 3) rather than queueing on a shared node.
2. A Single Request with await
request() only starts the request; its return value says whether it could be sent, not whether the server answered. The result arrives on request_completed(result, response_code, headers, body).
func fetch_motd() -> void:
var http := HTTPRequest.new()
http.timeout = 10.0 # Default 0.0 means wait forever.
add_child(http)
var err := http.request("https://api.example.com/v1/motd")
if err != OK:
push_error("Could not start request: %s" % error_string(err))
http.queue_free()
return
var res: Array = await http.request_completed
http.queue_free()
var result: int = res[0]
var code: int = res[1]
var body: PackedByteArray = res[3]
if result != HTTPRequest.RESULT_SUCCESS:
push_warning("Network failure (HTTPRequest.Result %d)" % result)
elif code != 200:
push_warning("Server answered HTTP %d" % code)
else:
print(body.get_string_from_utf8())
public async void FetchMotd()
{
var http = new HttpRequest { Timeout = 10.0 }; // Default 0.0 means wait forever.
AddChild(http);
Error err = http.Request("https://api.example.com/v1/motd");
if (err != Error.Ok)
{
GD.PushError($"Could not start request: {err}");
http.QueueFree();
return;
}
Variant[] res = await ToSignal(http, HttpRequest.SignalName.RequestCompleted);
http.QueueFree();
long result = res[0].AsInt64();
long code = res[1].AsInt64();
byte[] body = res[3].AsByteArray();
if (result != (long)HttpRequest.Result.Success)
GD.PushWarning($"Network failure (HttpRequest.Result {result})");
else if (code != 200)
GD.PushWarning($"Server answered HTTP {code}");
else
GD.Print(body.GetStringFromUtf8());
}
Check both values: result is the transport outcome (DNS, TLS, timeout — RESULT_CANT_RESOLVE, RESULT_TLS_HANDSHAKE_ERROR, RESULT_TIMEOUT, …), response_code is the HTTP status. A 404 or 500 arrives with RESULT_SUCCESS.
3. A Reusable JSON API Client (Autoload)
Wrap the boilerplate once. Register this as an autoload named Api; every call spawns its own HTTPRequest, so calls can overlap.
# api.gd — autoload "Api"
extends Node
const BASE_URL := "https://api.example.com/v1"
const TIMEOUT_SEC := 10.0
var auth_token: String = ""
## Returns {ok: bool, code: int, data: Variant, error: String}.
func request_json(path: String, method: HTTPClient.Method = HTTPClient.METHOD_GET, payload: Variant = null) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = TIMEOUT_SEC
add_child(http)
var headers := PackedStringArray(["Accept: application/json"])
var body := ""
if payload != null:
headers.append("Content-Type: application/json")
body = JSON.stringify(payload)
if not auth_token.is_empty():
headers.append("Authorization: Bearer " + auth_token)
var err := http.request(BASE_URL + path, headers, method, body)
if err != OK:
http.queue_free()
return {"ok": false, "code": 0, "data": null, "error": "request() failed: " + error_string(err)}
var res: Array = await http.request_completed
http.queue_free()
if res[0] != HTTPRequest.RESULT_SUCCESS:
return {"ok": false, "code": 0, "data": null, "error": "transport error %d" % res[0]}
var code: int = res[1]
var text := (res[3] as PackedByteArray).get_string_from_utf8()
var data: Variant = JSON.parse_string(text) if not text.is_empty() else null
return {"ok": code >= 200 and code < 300, "code": code, "data": data, "error": "" if code < 400 else text}
// Api.cs — autoload "Api"
using System.Collections.Generic;
using System.Threading.Tasks;
using Godot;
public partial class Api : Node
{
private const string BaseUrl = "https://api.example.com/v1";
private const double TimeoutSec = 10.0;
public string AuthToken { get; set; } = "";
public readonly record struct ApiResult(bool Ok, long Code, Variant Data, string Error);
public async Task<ApiResult> RequestJson(string path, HttpClient.Method method = HttpClient.Method.Get, Variant payload = default)
{
var http = new HttpRequest { Timeout = TimeoutSec };
AddChild(http);
var headers = new List<string> { "Accept: application/json" };
string body = "";
if (payload.VariantType != Variant.Type.Nil)
{
headers.Add("Content-Type: application/json");
body = Json.Stringify(payload);
}
if (AuthToken != "")
headers.Add("Authorization: Bearer " + AuthToken);
Error err = http.Request(BaseUrl + path, headers.ToArray(), method, body);
if (err != Error.Ok)
{
http.QueueFree();
return new ApiResult(false, 0, default, $"Request() failed: {err}");
}
Variant[] res = await ToSignal(http, HttpRequest.SignalName.RequestCompleted);
http.QueueFree();
if (res[0].AsInt64() != (long)HttpRequest.Result.Success)
return new ApiResult(false, 0, default, $"Transport error {res[0]}");
long code = res[1].AsInt64();
string text = res[3].AsByteArray().GetStringFromUtf8();
Variant data = text == "" ? default : Json.ParseString(text);
return new ApiResult(code is >= 200 and < 300, code, data, code < 400 ? "" : text);
}
}
Call it from anywhere:
var res := await Api.request_json("/scores", HTTPClient.METHOD_POST, {"name": "Ada", "score": 1200})
if not res.ok:
show_error(res.error)
var api = GetNode<Api>("/root/Api");
var res = await api.RequestJson("/scores", HttpClient.Method.Post, new Godot.Collections.Dictionary { { "name", "Ada" }, { "score", 1200 } });
if (!res.Ok)
ShowError(res.Error);
Retries with exponential backoff, request cancellation on scene change, and an auth refresh flow: references/rest-client-patterns.md.
4. JSON Pitfalls
- Every JSON number parses as
float. {"id": 5} gives 5.0. Cast before using it as an index, enum, or dictionary key: int(data["id"]) in GDScript, data["id"].AsInt32() in C#. == compares 5.0 == 5 as true, but match patterns are type-strict, and a dictionary keyed by 5 has no entry for 5.0.
JSON.parse_string() returns null on bad input and logs an error. When you need the line and message, use an instance: var json := JSON.new(); if json.parse(text) != OK: print(json.get_error_message(), json.get_error_line()).
- Validate shape before indexing. Check
data is Dictionary and data.has("items") — a server error page in HTML parses to null, and an empty array is still ok.
- A stringify → parse round trip turns ints into floats (
stringify itself writes 5, the parse brings back 5.0), and JSON.stringify(obj) only works on Variant types — convert custom objects or Resources to a Dictionary first.
5. Downloads, Uploads, and Images
| Task | How |
|---|
| Save straight to disk | Set http.download_file = "user://cache/pack.zip" before request(); the body is written there, not returned |
| Progress bar | Poll get_downloaded_bytes() against get_body_size() (-1 when the server sends no length, and always -1 on Web) |
| Cap the size | body_size_limit (bytes) → RESULT_BODY_SIZE_LIMIT_EXCEEDED |
| Binary upload | request_raw(url, headers, HTTPClient.METHOD_PUT, bytes) with a Content-Type header |
| Show a remote image | Image.new().load_png_from_buffer(body) (or _jpg_, _webp_), then ImageTexture.create_from_image(image) |
Image, form-encoded, and multipart recipes with C#: references/downloads-and-uploads.md.
6. TLS and Secrets
- HTTPS works out of the box against the bundled CA list. For a self-signed or private CA, load the
.crt and pass TLSOptions.client(cert) via http.set_tls_options(). TLSOptions.client_unsafe() skips validation — never ship it.
- Anything in the export is readable. API keys in scripts,
project.godot, or an encrypted PCK can be pulled out by a determined player. Keep privileged keys (payment, admin, third-party paid APIs) on a server you control and have the game call that server.
- Per-player tokens (session or refresh tokens) can live in
user://; treat them as revocable, not secret.
- Send credentials in headers or a POST body, never as GET query parameters — URLs end up in logs.
- TLS 1.2 is supported everywhere and TLS 1.3 from Godot 4.4; on 4.3 a TLS 1.3-only server fails with
RESULT_TLS_HANDSHAKE_ERROR, as does any server limited to TLS 1.1 or older.
7. Platform Notes
| Platform | Gotcha |
|---|
| Android | Enable the INTERNET permission in the export preset, or every request fails |
| Web | Requests go through the browser: the server must send CORS headers (Access-Control-Allow-Origin), and plain http:// from an https:// page is blocked as mixed content. See web-export |
| Dedicated server | Same API; run requests from the server so keys never reach clients — see dedicated-server |
8. WebSocket Client
Use WebSocketPeer for a persistent, server-pushed event stream; use HTTPRequest for request/response. Nothing happens unless you call poll() every frame (_process). Never put long-lived tokens in the URL — URLs end up in logs.
See references/websocket-client.md for the GDScript and C# client, wss://, auth, heartbeat, and reconnect rules.
9. Common Pitfalls
| Symptom | Cause | Fix |
|---|
request() returns ERR_BUSY (44) | Reused one HTTPRequest for overlapping calls | One node per in-flight request |
request() returns ERR_UNCONFIGURED | Node not in the tree yet | add_child() before request() |
| Request hangs forever | timeout left at 0.0 | Set timeout (5–15 s) on API calls; big downloads need a stall check instead (timeout covers the whole transfer) |
| Works in editor, fails on Android | Missing INTERNET permission | Tick it in the export preset |
| Works on desktop, fails on Web | CORS or mixed content | Fix server headers; use HTTPS/WSS |
id is 5.0, not 5 | JSON numbers are floats | Cast with int() |
Body empty, file appears in user:// | download_file was set | Clear it for in-memory requests |
| Request never completes after a scene change | The HTTPRequest was a child of a node that got freed, so the coroutine never resumes | Parent requests to an autoload (Section 3); don't touch freed nodes after the await |
10. Checklist