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
| Event | CLI | When |
|---|---|---|
CONNECTION_OPENED | connection-opened | Somebody connected to a game server. The earliest signal there is, and the only one for a server that is not running yet. |
CONNECTION_CLOSED | connection-closed | A connection ended. |
PLAYER_JOINED | player-joined | A named player joined, read from the server's own output. |
PLAYER_LEFT | player-left | A player left. Often worked out rather than announced — the delivery says which. |
SERVER_READY | server-ready | A game server finished starting and is accepting players. |
SERVER_STOPPING | server-stopping | A 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| Event | One of the six above. Required. |
| Game server | Deliver 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. |
| Path | Where in your sandbox the POST goes. Defaults to /. One sandbox can serve several triggers and route on the path. |
| Paused | Create 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:
POST /joined HTTP/1.1content-type: application/jsonce-specversion: 1.0ce-id: 01JB3M6Q4ZK7Y8W2N5R0T9HXCVce-type: cloud.plany.player_joinedce-source: projects//locations//gameServers/ce-subject: projects//locations//sandboxes//triggers/on-joince-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:
| Field | Events | |
|---|---|---|
type | all | The event, lower case: player_joined, server_ready, … |
gameServer, project, location, time | all | Where and when it happened. |
player | player events | The player's key, such as minecraft:<uuid> or steam:<id>. |
session | player events | The session's resource name. |
playerCount | player events | Players on the server after this event. |
provisional | player events | true while Plany is still learning to read this game's output, so the player may be a guess. See Accuracy for a new game. |
endReason | player_left | DISCONNECTED, SUPERSEDED, SERVER_STOPPED, SERVER_RESTARTED or EXPIRED. |
connection, clientIp, portName | connection events | The connection, the address it came from, and which of the server's ports it reached. |
wokeServer | connection events | true when this connection is what started a stopped server. |
game | server events | The 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 answer | What happens |
|---|---|
2xx | Delivered. |
404 or 410 | Given up at once — retrying cannot fix a path that is not there. |
| Anything else, a timeout, or a sandbox that could not start | Tried 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 deliveriesEach 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:
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.