Skip to content

http

Calls a service outside the world: a leaderboard site, a chat webhook, your own database, a payment API.

luau
local http = require("http")

Not for saving a player's progress

players.store already saves per player, per experience, 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

luau
http:send(request, onAnswer) -> true | nil, reason
luau
local 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)))
    end
end)

One verb. The method is a field, not a different function.

field
urlrequired. https:// only, port 443
methodGET (default), POST, PUT, PATCH, DELETE
headersnames are lower-cased; up to 16
bodyup 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.

luau
-- Wrong: `total` may be read before either answer arrived.
local total = 0
http: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 = 2
local function done()
    left = left - 1
    if left == 0 then print("both are in") end
end
http:send({ url = a }, done)
http:send({ url = b }, done)

The world moved on. The callback reads the world as it is now — 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

luau
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 all
end)

A 404 is not an error. Neither is a 500. Those are answers, and res.error stays nil for both — 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.

reasonwhat to do
permissionthe world has not granted net to this script's owner
http busytoo many requests waiting; back off and retry
http coolingthat address has been failing, and is left alone for 30 s
http quotathe world has spent its traffic for the day
too many requests in one eventan 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

luau
http:send({ url = "http://api.example.com/" })      -- nil, only https
http:send({ url = "https://api.example.com:8080/" })-- nil, only port 443
http:send({ url = "https://127.0.0.1/" })           -- nil, not reachable
http:send({ url = "https://user:pw@api.example.com/" }) -- nil, no userinfo

Only 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 — 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:

luau
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
    end
end)

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 — 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.

Hungrit scripting documentation.