http#
Calls a service outside the world: a leaderboard site, a chat webhook, your own database, a payment API.
Use it to
- post to a chat webhook when something happens
- read or update a leaderboard on your own site
- call any HTTPS service from inside the world
local http = require("http")Not for saving a player's progress
players.store already saves per player, per world, with no network in the way and no limits like the ones below. Reach for http when you need to talk to somebody else's service.
send#
http:send(request, onAnswer) -> true | nil, reasonlocal http = require("http")local json = require("json") http:send({ url = "https://api.example.com/score", method = "POST", headers = { ["content-type"] = "application/json" }, body = json.encode({ name = "ana", points = 42 }),}, function(res) if res.ok then print("saved") else print("failed: " .. (res.error or tostring(res.status))) endend)One verb. The method is a field, not a different function.
| field | |
|---|---|
url | required. https:// only, port 443 |
method | GET (default), POST, PUT, PATCH, DELETE |
headers | names are lower-cased; up to 16 |
body | up to 64 KB. A GET cannot carry one |
The callback is optional. Leave it out for a call whose answer you do not care about, like a notification.
The answer arrives later#
This is the one thing to hold on to. The internet takes time, and the world cannot stop and wait for it. Every script, the physics and every player standing in the region would freeze along with it. So send returns straight away and your function is called when the answer comes back.
Three consequences:
It is a new event. The callback gets a fresh instruction budget, and the count of things it may change in the world starts again at 8. It is not a continuation of the handler that called send.
The order is not promised. Two requests come back in whatever order the network settles them.
-- Wrong: `total` may be read before either answer arrived.local total = 0http:send({ url = a }, function(res) total = total + 1 end)http:send({ url = b }, function(res) total = total + 1 end)print(total) -- 0 -- Right: count inside, and act when the last one lands.local left = 2local function done() left = left - 1 if left == 0 then print("both are in") endendhttp:send({ url = a }, done)http:send({ url = b }, done)The world moved on. The callback reads the world as it is now, and a second is a long time in a region. The player who touched the button may have walked away; check before acting on them.
What comes back#
http:send({ url = "https://api.example.com/who" }, function(res) print(res.ok) -- true when it answered 2xx print(res.status) -- 200, 404, 500… (0 = no answer) print(res.body) -- text print(res.headers["content-type"]) -- names are lower-case print(res.error) -- nil when it answered at allend)A 404 is not an error. Neither is a 500. Those are answers, and res.error stays nil for both, so check res.ok or res.status for what the far end said. res.error is for when there was no answer: a timeout, a refused connection, a body too big to accept.
Refusals#
send answers nil and a reason instead of raising, like everything else in this API. A refusal here means the callback will not run. Nothing was sent.
| reason | what to do |
|---|---|
permission | the world has not granted net to this script's owner |
http busy | too many requests waiting; back off and retry |
http cooling | that address has been failing, and is left alone for 30 s |
http quota | the world has spent its traffic for the day |
too many requests in one event | an event may change at most 8 things, and a request is one |
http: … | the request itself: the address, a header or a size. The text says which |
Addresses that are refused#
http:send({ url = "http://api.example.com/" }) -- nil, only httpshttp:send({ url = "https://api.example.com:8080/" })-- nil, only port 443http:send({ url = "https://127.0.0.1/" }) -- nil, not reachablehttp:send({ url = "https://user:[email protected]/" }) -- nil, no userinfoOnly https://, only port 443, and only addresses out on the public internet. An address that points inside the machine hosting the world is refused, and so is a name that resolves to one, because the check happens against the address the connection is actually about to use.
Plain http:// is not allowed on purpose. In plain text, anyone between the world and the far end can not only read the answer but rewrite it, and the answer is what your script then acts on.
Redirects are not followed#
A 3xx comes back as an ordinary answer. Read it and decide:
http:send({ url = first }, function(res) if res.status == 301 or res.status == 302 then local where = res.headers["location"] if where then http:send({ url = where }, function(res2) end) end endend)Headers the engine owns#
host, content-length, connection, transfer-encoding, upgrade, expect, cookie and anything starting proxy- or x-hungrit- are set by the engine and cannot be changed. There are no cookies at all, so every request stands alone.
Every request carries x-hungrit-world and x-hungrit-object, so a service you call can tell your world apart from every other one.
An API key in a script is a public API key
Scripts are open. Anybody who can read yours can read the token in it. Until there is a place to keep a secret, treat any key you put in a script as published, and prefer a service of your own in front of the one holding the real key.
Limits#
See limits for the full table. In short: 10 requests a second per world, banking up to 60 for a burst; 64 KB out and 256 KB back; 10 seconds before it gives up.
Updated 17 October 2026