Endpoints
Endpoints are server-side flows. In v3, the same execution model covers both **publicly-callable endpoints** and **internal-only reusable endpoints**.
# Endpoints
Endpoints are server-side flows. In v3, the same execution model covers both **publicly-callable endpoints** and **internal-only reusable endpoints**.
- **Public endpoint** = callable over HTTP and through `NetworkStorage.CallEndpoint(...)`.
- **Internal endpoint** = endpoint-shaped reusable logic with `exposure: internal`; it has no public HTTP trigger and is only reached from other flows with `action: run`.
- **Workflow** = reusable flow identified by `id`. Legacy workflow source is still supported, but it runs through the same flow executor as endpoints.
Switching **Internal reusable flow** in the editor flips an endpoint between public and internal exposure without changing its step model. Public requests are authenticated once at the HTTP boundary; internal `run` calls stay in-process and reuse that caller context.
> **Authoring standard:** Use **YAML Source** for new endpoints. Runtime request and response bodies are JSON, while endpoint definitions should be authored in YAML Source. For the complete YAML syntax, including every step type, input, output, template helper, source-only primitive, and compiler option, see [Source Authoring](/wiki/network-storage-v3/source-authoring).
## Calling Endpoints From s&box
Use the **Network Storage by sboxcool.com** library from [sbox.game/sboxcool/network-storage](https://sbox.game/sboxcool/network-storage) or the GitHub source at [github.com/sbox-cool/sbox-network-storage](https://github.com/sbox-cool/sbox-network-storage). The library handles API versioning, s&box auth tokens, proxy mode, response parsing, and error logging.
```csharp
if ( !NetworkStorage.IsConfigured )
{
NetworkStorage.Configure( "YOUR_PROJECT_ID", "sbox_ns_YOUR_PUBLIC_KEY" );
}
var data = await NetworkStorage.CallEndpoint( "report-kill", new
{
target_type = "training_dummy"
} );
```
Call endpoints by slug. Passing an input object sends it as the endpoint input. Passing no input calls the endpoint without a request body:
```csharp
var player = await NetworkStorage.CallEndpoint( "load-player" );
```
## Creating an Endpoint
From your project page, click the **Endpoints** tab, then **New Endpoint**.
### 1. Fill in the basics
- **Name**: A readable name (for example `Report Kill`, `Purchase Item`)
- **Slug**: URL-safe identifier (for example `report-kill`, `purchase-item`) -- public endpoints use this in the API URL
- **Method**: `POST` for endpoints with input, or `GET` for read-only endpoint definitions. Game code should still use `NetworkStorage.CallEndpoint(...)`; the library chooses the request shape.
- **Exposure**: Leave the endpoint public when game clients or external callers should reach it. Enable **Internal reusable flow** when the same step graph should only be run from other endpoints/workflows via `action: run`.
### 2. Paste the endpoint source
The endpoint editor opens in **YAML Source** mode for new endpoints. Here's a complete `report-kill` endpoint you can paste directly:
```yml
sourceVersion: "1"
kind: endpoint
name: Report Kill
slug: report-kill
method: POST
enabled: true
input:
type: object
properties:
target_type:
type: string
required:
- target_type
steps:
- id: xp_amount
type: transform
value: "{{values.combat.xp_per_kill}}"
- id: gold_amount
type: transform
value: "{{values.combat.gold_per_kill}}"
- id: award
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: xp
value: "{{xp_amount}}"
source: combat
reason: "Killed {{input.target_type}}"
- op: inc
path: gold
value: "{{gold_amount}}"
source: combat
reason: "Loot from {{input.target_type}}"
- op: inc
path: stats.kills
value: 1
response:
status: 200
body:
success: true
xp_earned: "{{xp_amount}}"
gold_earned: "{{gold_amount}}"
```
### 3. How it works
- **`input`** defines what the game sends -- here it requires `target_type` (e.g. `"goblin_warrior"`)
- **`transform`** steps pull values from Game Values groups (`values.combat.xp_per_kill`) so you can rebalance from the dashboard
- **`write`** increments the player's `xp`, `gold`, and `stats.kills` in one operation. The `source` and `reason` fields provide audit trail metadata for ledger-tracked fields
- **`response`** returns the earned amounts back to the game client
### 4. Save and test
Click **Save Endpoint**, then call it from your game:
```csharp
var data = await NetworkStorage.CallEndpoint( "report-kill", new {
target_type = "goblin_warrior"
} );
if ( data.HasValue )
{
int xp = data.Value.Int( "xp_earned" );
int gold = data.Value.Int( "gold_earned" );
Log.Info( $"Kill rewarded! +{xp} XP, +{gold} gold" );
}
```
## Authentication
The library automatically attaches the public API key, the current player's Steam ID, and a `sbox-network-storage` auth token. In normal game code you do not build request URLs, query strings, or headers yourself.
```csharp
var ready = await NetworkStorage.EnsureEndpointAuthAsync( "report-kill" );
if ( !ready )
{
Log.Warning( "Network Storage auth is not ready yet." );
return;
}
var result = await NetworkStorage.CallEndpoint( "report-kill", new { target_type = "training_dummy" } );
```
When s&box auth is enabled on your project, public-key client calls verify the player's identity before executing any steps. When disabled, the supplied Steam ID is trusted as-is. Secret-key dedicated-server calls are treated as trusted server/backend calls and do not require s&box auth tokens.
Internal route calls do **not** issue a second HTTP request. `action: run` executes the target workflow or internal endpoint in-process with the same authenticated caller context, including `steamId`, `playerKey`, secret-key flags, and project auth decisions. Those identity fields are protected built-ins, so reusable flows can read them but cannot rebind them through params or step outputs.
### Secret-key gated endpoints
Enable **Requires secret key** for endpoints that should only be callable by dedicated servers or backend systems. The normal public key still identifies the project; the secret key proves the caller has server-side credentials.
Recommended raw HTTP headers:
```http
x-api-key: sbox_ns_YOUR_PUBLIC_KEY
x-secret-key: sbox_sk_YOUR_SECRET_KEY
```
The official s&box library uses the dedicated-server launch key `+network_storage_secret_key` as the primary key name. It also accepts namespaced aliases (`+network-storage-secret-key`, `+sboxcool_secret_key`, `+networkStorageSecretKey`, `+sboxcoolSecretKey`, `+nsSecretKey`, `+ns_secret_key`). Generic names such as `+secret-key`, `+secret_key`, and `+secretKey` are intentionally not supported to avoid colliding with other libraries.
For endpoint calls, the official library sends `x-secret-key: sbox_sk_...`, `x-public-key: sbox_ns_...`, plus an internal URL flag that indicates secret-key transport is in use. The actual secret key is never placed in the URL. This keeps s&box auth tokens out of dedicated-server requests.
A verified secret key adds these built-in variables to endpoint and workflow context:
- `{{_hasSecretKey}}` — `true` when the request included a valid secret key.
- `{{_isDedicatedServer}}` — alias of `_hasSecretKey` for dedicated-server workflows.
You can branch inside a shared endpoint/workflow:
```yml
- id: dedicated_only
type: condition
check:
field: _hasSecretKey
op: eq
value: true
onFail:
reject: true
status: 403
errorCode: DEDICATED_SERVER_REQUIRED
errorMessage: "This action requires a dedicated server secret."
```
Do not put secret keys in client bundles, public config, logs, or query strings. Dedicated servers should send the secret key instead of s&box auth tokens; the API accepts `x-secret-key` and compatible secret headers. Query-string secret-key fallback exists only for constrained integrations.
### Proxy Mode (On-Behalf-Of)
For dedicated servers where the host calls endpoints on behalf of clients, use the proxy helpers in the library:
```csharp
NetworkStorage.ProxyEnabled = true;
var result = await NetworkStorage.CallEndpointAs(
targetSteamId,
clientToken,
"purchase-item",
new { item_id = "iron_sword" }
);
```
Clients can route requests through your host by assigning `NetworkStorage.RequestProxy`; the bundled `NetworkStorageProxyComponent` provides this pattern.
When sbox auth is **enabled**: all three checks (host token, client token, signature) are verified. When sbox auth is **disabled**: the `x-on-behalf-of` header is trusted directly.
See the [s&box Auth](/wiki/network-storage-v3/sbox-auth) page for the full proxy mode documentation.
## Request Body
The request body contains your event data as JSON at runtime. Documentation examples show the same shape in YAML form:
```yml
target: goblin_warrior
zone: dark_forest
```
The body is your input data. Its shape is validated against the endpoint's input schema before any steps run. Authentication goes in headers or query parameters, not in the JSON body.
## Input Schema
Define what fields your endpoint accepts. Requests with missing or invalid fields are rejected before the pipeline runs.
```yml
target:
type: string
required: true
zone:
type: string
```
Optional fields that are not sent default to `null` in step references. Required fields cause an `INVALID_INPUT` error if missing.
## Step Types
Each endpoint is a pipeline of steps that execute in order. Every step has an `id` that other steps can reference.
### read
Loads data from a collection. The result is available to later steps as `{{stepId.field}}`.
```yml
id: player
type: read
collection: player_data
key: "{{playerKey}}"
```
After this step, `{{player.xp}}`, `{{player.gold}}`, `{{player.playerName}}` are available. You can reference any field defined in the collection's schema.
If the player has no data yet (first time), numeric fields default to `0`, strings default to `""`, and arrays default to `[]`.
### lookup
Fetches a value from Game Values. Used for table row lookups.
**Group values** do not require a lookup step. Reference them inline using `{{values.groupName.key}}`:
- `{{values.combat.xp_per_kill}}` -- value from the "combat" group
- `{{values.progression.xp_per_level}}` -- value from the "progression" group
This works in any step: transforms, conditions, write ops, and response templates.
**Table lookup** -- find a row by matching columns:
```yml
id: node
type: lookup
source: values
table: mining_nodes
where:
field: node_id
op: ==
value: "{{input.nodeId}}"
```
Result: `{{node.ore_amount}}`, `{{node.xp_required}}`, etc. -- every column in the matched row is available.
If no row matches, the result is `null` by default so legacy endpoints can handle the miss themselves. Set `required: true` to fail inline with a clear error:
```yml
id: item
type: lookup
source: values
table: shop_items
required: true
onMissing:
status: 400
errorCode: UNKNOWN_ITEM
message: Unknown item {{input.item_id}}.
where:
field: item_id
op: ==
value: "{{input.item_id}}"
```
`required` is also supported on `read`, `filter`, and `random_select`. For `filter`, an empty result array is considered missing only when `required: true`.
### filter
Queries multiple rows from a Game Values table. Returns an array of matching rows.
```yml
id: available_items
type: filter
source: values
table: shop_items
where:
all:
- field: xp_required
op: <=
value: "{{player.xp}}"
- field: category
op: ==
value: "{{input.category}}"
```
Result: `{{available_items.rows}}` -- array of matching rows. `{{available_items.count}}` -- number of matches.
Use filters for dynamic queries like "show all items the player qualifies for" or "list quests in this region."
### random_select
Filters records from a table or collection and returns one at random. Useful for loot drops, random encounters, or daily rewards.
```yml
id: loot
type: random_select
source: values
table: loot_table
where:
- field: tier
op: ==
value: "{{input.tier}}"
- field: min_level
op: <=
value: "{{player_level}}"
```
The `where` clause can be a single condition object, an `all`/`any` group, or an array of condition leaves. Arrays use AND logic -- all must match. Add `weightField` for weighted selection; omit it for uniform selection. After this step, every field on the selected record is available (e.g. `{{loot.item_id}}`, `{{loot.rarity}}`).
If no records match, the result is `null` unless `required: true` is set. Use `required` and `onMissing` when a missing random choice is invalid input rather than normal gameplay state.
### lookup_many
Loads several records from the same table or collection in one step. This is useful for fixed config rows such as worker definitions, bin definitions, or item tiers.
```yml
id: workers
type: lookup_many
source: values
table: process_workers
keyField: id
keys:
- process_apprentice
- process_pro_filleter
- process_veteran_cutter
```
By default, results are returned as a map keyed by the requested key:
```yml
"{{workers.process_apprentice.fishPerSecond}}"
```
Set `asMap: false` to return an array in the same order as `keys`. `keys` can also be a template that resolves to an array, such as `keys: "{{input.workerIds}}"`.
### condition
Evaluates a condition and stops the pipeline if it fails. Use this for validation logic that does not need the full Workflow system.
```yml
id: xp_check
type: condition
check:
left: "{{player.xp}}"
op: '>='
right: "{{node.xp_required}}"
onFail:
status: 403
error: XP_TOO_LOW
message: You need {{node.xp_required}} XP to mine this node. You have {{player.xp}}.
```
Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `in`, `not_contains`, `not_in`, `exists`, `not_exists`, `starts_with`, `not_starts_with`.
You can also use word aliases: `eq`, `neq`/`ne`, `gt`, `lt`, `gte`/`ge`, `lte`/`le`, `includes`/`has`, `not_includes`/`not_has`/`notcontains`, `notin`/`not_in`, `not_exist`/`notexists`, `startswith`/`notstartswith`. For example, `op: neq` is the same as `op: "!="`.
The `starts_with` operator checks if a string starts with a given prefix. The `not_starts_with` operator is its negation:
```yml
id: is_vip
type: condition
check:
field: "{{player.rank}}"
op: starts_with
value: vip_
onFail:
status: 403
error: VIP_REQUIRED
message: This action requires VIP rank.
```
The `not_contains` operator is the negation of `contains` -- it returns true if an array does not contain the value, or a string does not include the substring:
```yml
id: not_owned
type: condition
check:
field: "{{player.ownedVehicles}}"
op: not_contains
value: "{{input.vehicle_id}}"
onFail:
status: 409
error: ALREADY_OWNED
message: You already own this vehicle.
```
The `in` operator checks whether the right-hand array or string contains the field value. `not_in` is the inverse:
```yml
id: stat_known
type: condition
check:
field: "{{input.statId}}"
op: in
value:
- power
- reelForce
- control
onFail:
status: 400
error: UNKNOWN_STAT
```
**Field/value aliases:** Condition checks accept `left`/`right` as aliases for `field`/`value`. Both formats are equivalent:
```yml
# field/value form
field: "{{player.currency}}"
op: ">="
value: 100
# left/right alias form
left: "{{player.currency}}"
op: ">="
right: 100
```
You can use whichever form you prefer. They are interchangeable in single conditions and inside `all`/`any` arrays.
**Expression checks:** For single-use math predicates, put the expression directly on `check` instead of adding a separate `transform` step:
```yml
id: rod_owned
type: assert
check:
expression: "max(0, {{num(player.itemInventory.{{rod.id}}, 0)}}) > 0"
status: 409
errorCode: EQUIPPED_ROD_NOT_OWNED
message: Equipped rod is not owned.
```
Expression checks support a full comparison (`>`, `<`, `>=`, `<=`, `==`, `!=`) or a numeric truthiness check where non-zero passes. You can also combine `field`, `op`, and `expression` when the right-hand side needs math:
```yml
check:
field: player.gold
op: ">="
expression: "{{input.quantity}} * {{item.price}}"
```
You can also use compound conditions:
```yml
id: can_buy
type: condition
check:
all:
- left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
- left: "{{player.xp}}"
op: '>='
right: "{{item.xp_required}}"
onFail:
status: 403
error: PURCHASE_BLOCKED
message: You do not meet the requirements to purchase this item.
```
**Skipping optional branches:** Set `onFail` to `"skip"` to skip the next step and continue the endpoint. This is useful for optional write branches such as "promote this record only if the new value is better."
```yml
- id: is_heaviest
type: condition
check:
field: "{{fish.weight}}"
op: '>'
value: "{{player.records.heaviestWeightKg}}"
onFail: skip
- id: promote_heaviest
type: write
collection: players
key: "{{playerKey}}"
ops:
- op: set
path: records.heaviestWeightKg
value: "{{fish.weight}}"
```
**Grouped optional branches:** Use a `block` step with `when` when several child steps share one condition. This keeps the main pipeline readable and avoids repeating `condition -> skip` for every optional write:
```yml
- id: trophy_branch
type: block
when:
field: "{{disp.is_trophy}}"
op: ">"
value: 0
steps:
- id: save_trophy
type: write
collection: trophies
key: "{{playerKey}}"
ops:
- op: set
path: latest
value: "{{cast_payload}}"
- id: award_title
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: set_if_null
path: titles.first_trophy
value: true
```
**True/False routes:** Conditions can route both outcomes. The default true route continues; the default false route rejects. Use explicit routes when success or failure should return early, run reusable logic, or jump to another step.
```yml
- id: already_cached
type: condition
check:
field: "{{cache.hit}}"
op: ==
value: true
routes:
true:
action: return
status: 200
body:
ok: true
profile: "{{cache.profile}}"
false:
action: goto
step: load_profile
- id: load_profile
type: read
collection: player_data
key: "{{playerKey}}"
```
Routes support:
- `continue` -- run the next step.
- `reject` -- stop with an expected game-logic error. Public HTTP responses come back as `ok: false`, `status`, and `error.code` / `error.message`. Prefer 4xx statuses here so monitoring can distinguish expected rejections from server faults.
- `return` -- stop successfully and resolve the configured response body immediately.
- `goto` -- continue at a specific step id in the same endpoint.
- `run` -- execute a workflow or internal endpoint in-process, keep the same caller/auth context, and continue with its returned values.
In the visual builder, choosing `action: run` shows reusable-flow suggestions labeled as **public endpoint**, **internal endpoint**, or **workflow**. Parameter suggestions are hints only; use **Auto Fill** to populate any still-empty bindings from the best caller-context matches, then review them before saving.
**Loop and polling limits:** Backward `goto` branches and recursive `run` branches must be intentionally bounded.
- Total execution wall time is capped at **30000ms**.
- A single `sleep` or `retry.sleepMs` cannot exceed **1000ms**.
- `retry.maxAttempts` is the concrete per-step revisit limit and can be at most **10**.
- Without an explicit retry bound, a step can only be visited **20** times before the runtime rejects it.
- The runtime also caps total route transitions at **1000** per execution.
- `route.maxVisits` / `route.maxTransitions` tell validation that a backward or recursive branch is intentionally bounded. Use them as metadata only; they do not replace `retry.maxAttempts` for actual step execution.
**Bounded polling:** Use `sleep` and explicit retry bounds when intentionally looping back to re-check state. Sleep is a short in-request delay and still counts against endpoint wall-time limits.
```yml
- id: wait
type: sleep
durationMs: 250
- id: ready
type: condition
check:
field: "{{status.ready}}"
op: ==
value: true
retry:
maxAttempts: 5
sleepMs: 250
maxElapsedMs: 1500
routes:
true: { action: continue }
false: { action: goto, step: wait }
```
### assert
Fails immediately when a condition is false. Use this when there is no alternate branch and you want one clear error response.
```yml
id: has_gold
type: assert
check:
field: "{{player.gold}}"
op: ">="
value: "{{input.cost}}"
status: 403
errorCode: NOT_ENOUGH_GOLD
message: You need more gold.
```
Unlike `condition`, `assert` always rejects on failure. It is the cleanest choice for hard requirements such as ownership, rank, currency, or input invariants.
### transform
Computes a new value from existing step results. Useful for calculations before writing.
```yml
id: xp_reward
type: transform
expression: "{{values.combat.xp_per_kill}} * {{input.difficulty}}"
```
Result: `{{xp_reward}}` -- the computed value.
**Math expressions** support arithmetic operators and built-in functions:
| Operator/Function | Example | Description |
|-------------------|---------|-------------|
| `+` `-` `*` `/` | `{{a}} + {{b}}` | Basic arithmetic |
| `%` | `{{a}} % {{b}}` | Modulo (remainder) |
| `floor()` | `floor({{a}} / {{b}})` | Round down to integer |
| `ceil()` | `ceil({{a}} / {{b}})` | Round up to integer |
| `round()` | `round({{a}} / {{b}})` | Round to nearest integer |
| `min()` | `min({{a}}, {{b}})` | Smallest of 2+ values |
| `max()` | `max({{a}}, {{b}})` | Largest of 2+ values |
| `abs()` | `abs({{a}} - {{b}})` | Absolute value |
| `random()` | `random(1, 10)` | Random integer (inclusive) |
| `pow()` | `pow({{a}}, 2)` | Exponentiation |
| `clamp()` | `clamp({{a}}, 0, 100)` | Clamp value to range |
| `now()` | `now()` | Current Unix timestamp in milliseconds |
| `nowS()` | `nowS()` | Current Unix timestamp in seconds |
| `hour()` | `hour()` | Current UTC hour (0–23) |
| `dayOfWeek()` | `dayOfWeek()` | Current UTC day of week (0=Sun, 6=Sat) |
| `diffMs()` | `diffMs(now(), {{start}})` | Difference in milliseconds (a - b) |
| `diffS()` | `diffS(now(), {{start}})` | Difference in seconds (a - b) / 1000 |
> For string-based dates, use the context variables `{{_dateUTC}}`, `{{_timeUTC}}`, `{{_datetimeUTC}}` instead of functions.
**Computing level from XP** -- a common pattern using Game Values:
```yml
id: current_level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
```
This computes the player's level dynamically. The `xp_per_level` value comes from a Game Values group, so you can rebalance progression without migrating data.
**Chaining transforms** -- reference previous transform results:
```yml
- id: new_xp
type: transform
expression: "{{player.xp}} + {{values.combat.xp_per_kill}}"
- id: new_level
type: transform
expression: floor({{new_xp}} / {{values.progression.xp_per_level}})
```
### object
Builds one object from named fields, templates, and literals.
```yml
id: response_meta
type: object
fields:
steamId: "{{steamId}}"
mode: "{{input.mode}}"
rewardsGranted: true
```
Result: `{{response_meta.steamId}}`, `{{response_meta.mode}}`, and other object fields.
### array
Builds one ordered array from templates, literals, and nested values.
```yml
id: reward_list
type: array
items:
- starter_pack
- "{{input.extraReward}}"
- kind: gold
amount: "{{input.gold}}"
```
Result: `{{reward_list.0}}`, `{{reward_list.1}}`, and so on.
### merge
Shallow-merges one or more objects into a new object. Later sources overwrite earlier properties.
```yml
id: payload
type: merge
sources:
- "{{player}}"
- title: Rookie
grantedRewards: "{{reward_list}}"
```
This is clearer than hiding object merges inside a dense transform expression.
### sort
Sorts an array into a new array. The original source array is left untouched.
```yml
id: leaderboard
type: sort
source: "{{input.entries}}"
by: score
direction: desc
```
Use `by` for a plain item field path such as `score` or `stats.rank`. For more advanced ordering, set `expression` instead of `by`, for example `"{{item.stats.score}}"`.
### switch
Chooses the first matching case value from an ordered list of conditions.
```yml
id: reward_tier
type: switch
cases:
- when:
field: "input.mode"
op: "=="
value: admin
then: admin
- when:
field: "input.gold"
op: ">="
value: 100
then: rich
default: starter
```
This is easier to read than chaining several transforms just to select one value. Use the result as `{{reward_tier}}`.
### compute
Computes several named values in one step. Values can reference other fields on the same compute step in any order; dependencies are resolved iteratively.
```yml
id: calc
type: compute
values:
elapsed: min(max(0, {{input.nowUnix}} - {{num(player.lastTickUnixSeconds, 0)}}),
{{values.offline.cap_hours}} * 3600)
catchRate: "{{num(player.fishermanCount, 0)}} * {{values.fishermen.base_per_second}}"
catchBudget: floor({{calc.elapsed}} * {{calc.catchRate}})
```
Reference compute outputs with `{{calc.elapsed}}`, `{{calc.catchRate}}`, etc. Use `value: "{{template}}"` when you want template resolution instead of math evaluation.
By default, compute outputs are grouped under the step id (`output: object`), which keeps large payloads together:
```yml
- id: cast_payload
type: compute
output: object
values:
castId: "c{{cast_id_num}}"
biomeId: "{{input.biomeId}}"
waitSeconds: "max(0.25, {{base_wait_seconds}} / (1 + {{calc.attraction_level}} * 0.1))"
```
Use `output: scalars` only when you intentionally want each computed key available as a top-level template like `{{castId}}`. Object mode is the backward-compatible default and is recommended for structured payloads.
### random
Generates secure server-side random values. Use this for game-critical RNG instead of client rolls.
```yml
- id: roll
type: random
mode: int
min: 1
max: 101
- id: size
type: random
mode: beta
alpha: 1
beta: 4
min: 0.5
max: 10
precision: 2
```
For `mode: "int"`, `max` is exclusive. Use `min: 1, max: 101` for a 1-100 roll.
Weighted mode can pick from inline items, a previous array, or a Game Values table:
```yml
id: loot
type: random
mode: weighted
source: values
table: loot_table
weightField: dropWeight
```
### write
Writes data to a collection. Supports `set`, `inc`, `push`, `pull`, `remove`/`delete`, `merge`, and `set_if_null` operations.
```yml
id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: xp
value: "{{xp_reward}}"
source: combat_xp
reason: Killed {{input.target}} (difficulty {{input.difficulty}})
- op: inc
path: gold
value: "{{values.combat.gold_per_kill}}"
source: npc_reward
reason: Loot from {{input.target}}
```
Use `valueExpression` when an op value needs math but does not deserve a separate transform step:
```yml
id: sell_fish
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: gold
valueExpression: "{{player.inventory.{{input.fishIndex}}.value}}"
- op: inc
path: bins.{{input.binId}}.totalValue
valueExpression: 0 - {{player.inventory.{{input.fishIndex}}.value}}
- op: set
path: inventory.{{input.fishIndex}}.sold
value: true
```
`valueExpression` uses the same safe math evaluator as transform steps. The runner resolves it into a normal numeric `value` before applying the operation, so schema validation, rate limits, ledger logging, and dry-run previews behave the same as regular write ops.
Use `merge` when you already built an object with an `object`, `merge`, or `compute output: object` step and want to write it in one op:
```yml
- id: pending
type: object
fields:
castId: "{{cast_id}}"
biomeId: "{{input.biomeId}}"
speciesId: "{{species_roll.speciesId}}"
- id: save_cast
type: write
collection: pending_casts
key: "{{playerKey}}"
ops:
- op: merge
path: ""
value: "{{pending}}"
```
`path: ""` merges into the record root. Use a nested path such as `stats.session` to merge into an object field. `set_if_null` writes only when the existing value is missing or null, which is useful for first-time flags and one-time grants.
Use `when` on an individual op when several optional branches all write to the same record:
```yml
id: tick_outputs
type: write
collection: factory_state
key: "{{playerKey}}"
ops:
- op: inc
path: storage.ore
value: 1
when:
field: route.targetType
op: ==
value: storage
- op: inc
path: processors.{{route.targetId}}.input
value: 1
when:
field: route.targetType
op: ==
value: processing_station
- op: set
path: lastTickAt
value: "{{_unixS}}"
```
`when` uses the same condition syntax as a `condition` step. If it fails, only that operation is skipped; the rest of the write step still runs. This is the preferred way to collapse repeated `condition -> write` skip branches that all target the same record.
**Available operations:**
| Op | Description | Example |
|----|-------------|---------|
| `set` | Set a field to a value | `op: set`, `path: playerName`, `value: Hero` |
| `inc` | Increment a numeric field | `op: inc`, `path: xp`, `value: 50` |
| `push` | Append to an array | `op: push`, `path: inventory`, `value.itemId: sword` |
| `pull` | Remove array items by match object | `op: pull`, `path: tags`, `match.value: old_tag` |
| `remove` | Remove from an array by value | `op: remove`, `path: ownedVehicles`, `value: voss_moonrider` |
| `delete` | Alias for `remove` | `op: delete`, `path: ownedVehicles`, `value: voss_moonrider` |
| `merge` | Shallow-merge an object into an existing object or record root | `op: merge`, `path: ""`, `value: "{{payload}}"` |
| `set_if_null` | Set a field only when it is missing or null | `op: set_if_null`, `path: firstLoginAt`, `value: "{{_unixS}}"` |
**`remove` operation** -- removes an element from an array by exact value match. Works with both primitives and objects:
```yml
# Remove a primitive value
op: remove
path: ownedVehicles
value: voss_moonrider
# Remove an object by matching all fields
op: remove
path: inventory
value:
item_id: sword
```
For objects, the element is removed when all fields in `value` match. Extra fields on the stored object are ignored during comparison -- only the fields you specify need to match.
**`pull` with `match.value`** -- the `pull` operation also supports removing by primitive value using `match.value`:
```yml
op: pull
path: tags
match:
value: old_tag
```
This is equivalent to `op: remove`, `path: tags`, `value: old_tag`, but uses the `pull` syntax for consistency with field-based `pull` matching.
Write steps automatically enforce:
- Schema validation on the target collection
- Rate limit rules assigned to the collection/field
- Ledger logging for `_ledger: true` fields
- Stale save detection via `_saveSeq`
The `source` and `reason` fields on each op are metadata for the ledger audit trail. They are required on `_ledger: true` fields and optional otherwise.
**Bypassing rate limits** on specific write steps using `rateLimitBypasses`. Each key is a rate limit rule ID, and the value is a condition that must pass for the bypass to apply:
```yml
id: admin_grant
type: write
collection: player_data
key: "{{playerKey}}"
rateLimitBypasses:
xp_daily_cap:
field: "{{player.role}}"
op: ==
value: admin
ops:
- op: inc
path: xp
value: 10000
source: admin_grant
reason: 'Compensation for bug #1234'
```
In this example, the `xp_daily_cap` rate limit rule is bypassed only if the player's role is `"admin"`. If the condition fails, the rate limit applies normally.
### delete
Removes a record entirely from a collection. Use this for full resets or cleanup operations.
```yml
id: wipe
type: delete
collection: players
key: "{{playerKey}}"
```
Like `write` steps, `delete` steps are deferred until all conditions in the pipeline pass -- no data is deleted if any step fails. If the record does not exist (404), the delete is treated as success (the record is already gone).
**Use case: full player reset** -- delete existing data, then write fresh defaults:
```yml
steps:
- id: wipe
type: delete
collection: player_data
key: "{{playerKey}}"
- id: fresh
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: set
path: currency
value: 0
- op: set
path: xp
value: 0
```
### workflow
Executes a reusable multi-step workflow within the endpoint pipeline. Workflows encapsulate validation logic (lookups, conditions, transforms) that can be shared across endpoints.
```yml
id: validate
type: workflow
workflow: validate_purchase
params:
player: "{{player}}"
item_id: "{{input.vehicle_id}}"
item_table: vehicles
owned_field: ownedVehicles
```
The workflow runs its internal steps and maps its return values back into the endpoint context under the step ID. After this step, you can reference workflow outputs like `{{validate.item}}` or `{{validate.cost}}` in subsequent steps.
Workflows support all step types -- read, write, delete, lookup, filter, random_select, transform, condition, and nested workflow calls. Write and delete steps inside workflows are deferred alongside the endpoint's own writes, maintaining atomicity. If any condition fails anywhere in the pipeline, no data is written.
If a condition inside the workflow fails, the endpoint pipeline stops and returns the workflow's error (just like an inline condition failure).
Workflows can nest up to 8 levels deep. Read/lookup/filter steps are tracked globally across all nested workflows, with a limit of 10 total.
See the [Workflows](/wiki/network-storage-v3/workflows) page for the full workflow definition format and examples.
## Step Aliases
Any step can use the `as` field to store its result under a different name in the context. This is useful when you want a more descriptive name or to avoid collisions.
```yml
id: step_1
as: player
type: read
collection: player_data
key: "{{playerKey}}"
```
After this step, the data is available as `{{player.xp}}` instead of `{{step_1.xp}}`. The `id` is still used for timing and error reporting, but `as` controls the context key.
## Template Variables
Template variables use `{{...}}` syntax to reference values from the pipeline context.
### Available context
| Variable | Type | Description | Example |
|----------|------|-------------|---------|
| `{{input.fieldName}}` | any | Validated request body fields | |
| `{{steamId}}` | string | Caller's Steam ID | `"76561198000001"` |
| `{{playerKey}}` | string | Multi-save aware key | `"76561198000001_default"` |
| `{{now}}` | string | ISO 8601 UTC timestamp | `"2025-03-30T12:34:56.789Z"` |
| `{{_unixMs}}` | number | Unix epoch milliseconds | `1743339296789` |
| `{{_unixS}}` | number | Unix epoch seconds | `1743339296` |
| `{{_dateUTC}}` | string | UTC date | `"2025-03-30"` |
| `{{_timeUTC}}` | string | UTC time | `"12:34:56"` |
| `{{_datetimeUTC}}` | string | UTC datetime (no ms) | `"2025-03-30T12:34:56Z"` |
| `{{_year}}` | number | UTC year | `2025` |
| `{{_month}}` | number | UTC month (1–12) | `3` |
| `{{_day}}` | number | UTC day of month (1–31) | `30` |
| `{{_hour}}` | number | UTC hour (0–23) | `12` |
| `{{_minute}}` | number | UTC minute (0–59) | `34` |
| `{{_dayOfWeek}}` | number | UTC day of week (0=Sun, 6=Sat) | `0` |
| `{{values.groupName.key}}` | any | Game Values (groups and tables) | |
| `{{stepId.fieldPath}}` | any | Data from a previous step | |
| `{{stepId._stats}}` | object | Player stats auto-attached to read steps | |
> All timestamp variables are captured at the same instant when execution begins — they are always consistent within a single request.
### Negation prefix
Prefix a template variable with `-` to negate a numeric value. This is useful for decrementing in write operations without a separate transform step:
```yml
op: inc
path: currency
value: "{{-item.cost}}"
```
This resolves `item.cost` (e.g. `500`) and negates it (e.g. `-500`), effectively subtracting the cost. If the referenced value is not a number, an error is thrown.
### Helper tokens
Use helper tokens to keep endpoint source small and avoid nested-template paths.
| Helper | Example | Description |
|--------|---------|-------------|
| `num(value, fallback)` | `{{num(player.gold, 0)}}` | Converts to number, using fallback when missing or invalid |
| `coalesce(a, b, fallback)` | `{{coalesce(player.rank, "player")}}` | First non-empty value |
| `default(a, fallback)` | `{{default(player.title, "Rookie")}}` | Alias for `coalesce` |
| `get(root, path..., fallback)` | `{{get(player, bins, input.binId, totalQty, 0)}}` | Safe dynamic path lookup |
`get()` is the preferred replacement for nested templates like `{{player.bins.{{input.binId}}.totalQty}}`.
Nested templates inside paths are also supported directly. For example, `{{player.inventory.{{input.fishIndex}}.value}}` resolves `input.fishIndex` first, then reads the inventory item value. Use this for compact indexed paths; use `get()` when you need a fallback value for missing paths.
### Template aliases (`let`)
Use endpoint-level `let` aliases for long source paths that appear repeatedly. Aliases are explicit metadata, not YAML anchors, and are resolved by the endpoint template engine:
```yml
let:
chum: player.activeChum
rodStats: player.fishingRodStats
steps:
- id: chum_active
type: assert
check:
field: "{{chum.expiresAtUnixSeconds}}"
op: ">"
value: "{{_unixS}}"
status: 409
errorCode: CHUM_EXPIRED
```
Any step can also define `let` to add aliases before that step runs. Later steps can use those aliases too. Prefer aliases for repeated, meaningful domain paths; avoid aliases that hide important ownership or security boundaries.
## Response Template
Define what the endpoint returns to the game client. Reference step results with `{{stepId.field}}`.
```yml
response:
xp: "{{player.xp}}"
gold: "{{player.gold}}"
level: "{{current_level}}"
xpAwarded: "{{xp_reward}}"
```
The response is built after all steps complete successfully. If any step fails, the pipeline stops and returns the error from that step.
Note that `level` in the response is a computed value from a transform step, not a stored field. The client receives the level for display, but it is never persisted.
### Response echo mode
For responses that mostly mirror step outputs, use `response.echo` to build the body from selected templates. The response key is the last path segment in the echoed template:
```yml
response:
status: 200
body:
ok: true
throttleActive: 0
echo:
- "{{cast_payload}}"
- "{{species_roll.speciesId}}"
- "{{fish_rarity.rarityId}}"
- "{{weight_class.name}}"
```
This returns `cast_payload`, `speciesId`, `rarityId`, and `name` from the echoed templates. Echo mode reduces response/write drift when the response should reflect objects the endpoint already built. If `echo` is omitted, the normal `body` template is used.
## Timing Header
Every endpoint response includes the `X-Endpoint-Timing` header showing how long each step took:
```
X-Endpoint-Timing: read:2ms, lookup:1ms, transform:0ms, condition:0ms, write:4ms, total:7ms
```
Use this to identify slow steps and optimize your pipelines.
## Error Handling
Expected game-logic failures and runtime failures both come back as JSON. Public endpoint responses keep the top-level shape stable:
```yml
ok: false
status: 403
error:
code: XP_TOO_LOW
message: You need 5000 XP to mine this node. You have 1200.
requestId: req_...
```
Some runtime errors also include a `docsUrl` field pointing to deeper guidance:
```yml
ok: false
status: 401
error:
code: SBOX_AUTH_FAILED
message: s&box auth token verification failed.
docsUrl: https://sboxcool.com/wiki/network-storage-v3/sbox-auth
requestId: req_...
```
Common error codes:
| Code | Cause |
| ----- | ----- |
| `INVALID_INPUT` | Request body failed schema validation |
| `CONDITION_FAILED` | A condition route or assert rejected |
| `FLOW_STEP_VISIT_LIMIT_EXCEEDED` | A loop revisited a step too often |
| `FLOW_ROUTE_LIMIT_EXCEEDED` | Route transitions exceeded the cap |
| `RATE_LIMITED` | A write hit a rate limit rule |
| `SCHEMA_VALIDATION` | Written data failed schema validation |
| `NOT_FOUND` | A read or lookup found no data |
| `AUTH_FAILED` / `SBOX_AUTH_FAILED` | Caller auth failed |
## Full C# Example
Install **Network Storage by sboxcool.com** from
[sbox.game/sboxcool/network-storage](https://sbox.game/sboxcool/network-storage)
or use the GitHub source at
[github.com/sbox-cool/sbox-network-storage](https://github.com/sbox-cool/sbox-network-storage).
Configure once, then call endpoints by slug:
```csharp
namespace Sandbox;
public static class MyNetworkStorage
{
private const string ProjectId = "YOUR_PROJECT_ID";
private const string PublicKey = "sbox_ns_YOUR_PUBLIC_KEY";
public static void Initialize()
{
if ( !NetworkStorage.IsConfigured )
NetworkStorage.Configure( ProjectId, PublicKey );
}
public static async Task AwardKill()
{
Initialize();
var data = await NetworkStorage.CallEndpoint( "report-kill", new
{
target_type = "training_dummy"
} );
if ( !data.HasValue )
return;
var xp = data.Value.Int( "xp_earned" );
var gold = data.Value.Int( "gold_earned" );
Log.Info( $"Kill rewarded! XP: {xp}, Gold: {gold}" );
}
}
```
Purchase example:
```csharp
var data = await NetworkStorage.CallEndpoint( "purchase-item", new
{
itemId = "iron_sword"
} );
if ( data.HasValue )
{
Log.Info( $"Purchased {data.Value.Str( "itemName", "item" )}!" );
}
```
## System Limits
| Limit | Value |
|-------|-------|
| Max steps per endpoint | 500 |
| Max read/lookup/filter/random_select steps | 25 (across entire execution including workflows) |
| Max operations per write step | 100 |
| Max workflow nesting depth | 8 |
| Max collection scan records | 500 |
| Max template string length | 10,000 characters |
| Max path depth (dot segments) | 10 |
| Max math expression length | 1,000 characters |
| Max `source` metadata length | 64 characters |
| Max `reason` metadata length | 256 characters |
## Common Pitfalls
### Transform results don't use `.result`
Transform step outputs are stored directly under the step ID. Use `{{stepId}}`, not `{{stepId.result}}`.
```yml
# WRONG — will cause "Unresolved variable" error
expression: "{{player.xp}} + {{xp_reward.result}}"
# CORRECT
expression: "{{player.xp}} + {{xp_reward}}"
```
### Group values don't need a lookup step
Game Values groups are available everywhere as `{{values.groupName.key}}`. A lookup step is only needed for table row searches.
```yml
# WRONG — group/key lookup doesn't exist
id: prog
type: lookup
source: values
group: progression
key: xp_per_level
# CORRECT — reference inline
id: level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
```
### Collection names must match exactly
The collection name in your endpoint must match the name shown in your dashboard, not a local alias. Check your dashboard if you get a "collection not found" error.
### Table lookup requires explicit `where` format
Table lookups, filters, and random selects need explicit `field`, `op`, and `value` condition leaves -- not shorthand key matching.
```yml
# WRONG
where:
item_id: "{{input.itemId}}"
# CORRECT
where:
field: item_id
op: "=="
value: "{{input.itemId}}"
```
## Pipeline Execution Order
1. **Input validation** -- reject if input does not match schema
2. **s&box auth** -- verify Steam identity (if enabled)
3. **Steps execute in order** -- each step can reference results from previous steps
4. **Rate limits checked** -- on every write step, before data is saved
5. **Workflows run** -- bound workflows validate at their configured step
6. **Data written** -- only after all validations pass
7. **Response built** -- from the response template using final step results
If any step fails, no data is written. The pipeline is atomic -- partial writes do not happen.
## Multiple Endpoint Examples
### Simple stat tracker
An endpoint that increments a play-time counter. No input needed -- the endpoint awards a fixed amount.
```yml
slug: heartbeat
steps:
- id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: playTimeMinutes
value: 1
source: heartbeat
reason: Play session tick
response:
playTime: "{{save.playTimeMinutes}}"
```
```csharp
// Call every 60 seconds from game client
var result = await NetworkStorage.CallEndpoint( "heartbeat" );
```
### Item lookup
An endpoint that returns data without writing. Useful for shop UIs or checking player stats.
```csharp
var data = await NetworkStorage.CallEndpoint( "get-player-stats" );
if ( data.HasValue )
{
Log.Info( $"Player XP: {data.Value.Int( "xp" )}, computed level: {data.Value.Int( "level" )}" );
}
```