PlanyDocs
Sandbox

Triggers

Run your sandbox when something happens in one of your games — a player joining or leaving, a server starting or stopping — delivered as an HTTP request.

projects/{project}/locations/{location}/sandboxes/{sandbox}/triggers/{trigger}

A trigger delivers something that happened on one of your game servers to your sandbox, as an HTTP POST. Plany already knows when a player joins a server, when somebody connects to one, and when a server starts or stops. A trigger is your code attached to one of those moments: you write what should happen when a player joins, and nothing else — no listener to host, scale or pay for while nobody is joining.

A delivery takes the same path as any other request. It starts the sandbox from zero if nothing is running, counts towards its concurrency, and is billed as one request. A sandbox that is internal has no URL at all and still receives its triggers.

Events

EventCLIWhen
CONNECTION_OPENEDconnection-openedSomebody connected to a game server. The earliest signal there is, and the only one for a server that is not running yet.
CONNECTION_CLOSEDconnection-closedA connection ended.
PLAYER_JOINEDplayer-joinedA named player joined, read from the server's own output.
PLAYER_LEFTplayer-leftA player left. Often worked out rather than announced — the delivery says which.
SERVER_READYserver-readyA game server finished starting and is accepting players.
SERVER_STOPPINGserver-stoppingA game server is shutting down.

A connection and a player are different things: one person may open several connections, and several people may share an address. See Players and sessions.

Creating a trigger

Sandboxes → hooks → Triggers → New trigger → When: A player joins → On: Any server in this project → Delivered to: /joined → Create trigger
EventOne of the six above. Required.
Game serverDeliver only events about this server, in the sandbox's own project and region. Leave it out for every game server in the project. --game-server in the CLI, game_server in Terraform.
PathWhere in your sandbox the POST goes. Defaults to /. One sandbox can serve several triggers and route on the path.
PausedCreate it paused with --paused (paused = true in Terraform).

Narrow a trigger to one server when it belongs to one lobby or one fleet; leave it wide for something every server in the project should feed, like analytics.

What your sandbox receives

A JSON body, and headers in the CloudEvents binary format, which most web frameworks already understand:

HTTP
POST /joined HTTP/1.1
content-type: application/json
ce-specversion: 1.0
ce-id: 01JB3M6Q4ZK7Y8W2N5R0T9HXCV
ce-type: cloud.plany.player_joined
ce-source: projects//locations//gameServers/
ce-subject: projects//locations//sandboxes//triggers/on-join
ce-time: 2026-10-04T18:22:31.402Z
{
"type": "player_joined",
"gameServer": "projects//locations//gameServers/",
"project": "projects/",
"location": "",
"player": "minecraft:069a79f4-44e9-4726-a5be-fca90e38aaf5",
"session": "projects//locations//gameServers//playerSessions/ps-8f2k1",
"playerCount": 4,
"provisional": false,
"time": "2026-10-04T18:22:31.402Z"
}

ce-id identifies the event and ce-subject the trigger that delivered it. The body's fields depend on the event:

FieldEvents
typeallThe event, lower case: player_joined, server_ready, …
gameServer, project, location, timeallWhere and when it happened.
playerplayer eventsThe player's key, such as minecraft:<uuid> or steam:<id>.
sessionplayer eventsThe session's resource name.
playerCountplayer eventsPlayers on the server after this event.
provisionalplayer eventstrue while Plany is still learning to read this game's output, so the player may be a guess. See Accuracy for a new game.
endReasonplayer_leftDISCONNECTED, SUPERSEDED, SERVER_STOPPED, SERVER_RESTARTED or EXPIRED.
connection, clientIp, portNameconnection eventsThe connection, the address it came from, and which of the server's ports it reached.
wokeServerconnection eventstrue when this connection is what started a stopped server.
gameserver eventsThe game the server runs.

Only DISCONNECTED means somebody was seen leaving. The others mean the departure was inferred, and EXPIRED is an upper bound on when they left rather than a measurement. Code that charges or rewards by playtime should treat it that way.

Answering a delivery

Answer with any 2xx status once you have handled the event.

Your answerWhat happens
2xxDelivered.
404 or 410Given up at once — retrying cannot fix a path that is not there.
Anything else, a timeout, or a sandbox that could not startTried again after 4 seconds, then after 9 more. Three attempts in all.

Events about the same game server are delivered one at a time, in the order they happened, so keep a handler quick. Occasionally an event may arrive more than once; use ce-id to recognise a repeat.

When deliveries fail

A delivery that runs out of attempts is not dropped silently. It is recorded with the reason, kept for 14 days, and counted on the trigger as failed deliveries — because a trigger that has stopped working otherwise looks exactly like a quiet evening.

Sandboxes → hooks → Failed deliveries

Each one names the trigger, the event, the game server, the last reason it failed and how many times it was tried.

Pausing, changing and deleting

When a sandbox is failing every delivery, pause its trigger rather than deleting it: nothing is delivered while it is paused, and it keeps its configuration for when you resume it. Events that happen while a trigger is paused are not delivered later.

Sandboxes → hooks → Triggers → ⋯ → Pause, Edit or Delete

--game-server '' widens a trigger back to every game server in the project.

Example: a welcome message

An internal sandbox that is only ever started by its trigger, handling one join at a time:

Terraform
resource "plany_sandbox" "hooks" {
sandbox_id = "player-hooks"
image = "${plany_registry_repository.backend.name}:v1"
vcpus = 1
memory_mib = 256
ingress = "INTERNAL"
max_concurrency = 1
max_instances = 5
}
resource "plany_sandbox_trigger" "player_joined" {
sandbox = plany_sandbox.hooks.name
trigger_id = "player-joined"
event_type = "PLAYER_JOINED"
game_server = plany_playpute_game_server.lobby.name
path = "/events/player-joined"
}

Nothing runs, and nothing is charged, until somebody joins the lobby.