Endpoint Standards
Best practices and conventions for designing reliable, maintainable public endpoints and internal reusable flows in Network Storage v3.
# Endpoint Standards
Best practices and conventions for designing reliable, maintainable public endpoints and internal reusable flows in Network Storage v3.
Use **YAML Source** for authoring. The same guidance applies whether a flow is exposed publicly or marked `exposure: internal` for reuse only.
## Naming Conventions
### Endpoint Slugs
Use lowercase kebab-case verbs that describe what the endpoint does. The slug becomes part of the API URL, so keep it short and descriptive.
**Good slugs:**
| Slug | Purpose |
|------|---------|
| `report-kill` | Record a player kill |
| `purchase-item` | Buy an item from the shop |
| `claim-reward` | Claim a daily or quest reward |
| `get-leaderboard` | Fetch leaderboard data |
| `submit-score` | Submit a round score |
| `reset-progress` | Wipe a player's data |
**Avoid:**
| Bad Slug | Why | Better |
|----------|-----|--------|
| `kill` | Too vague -- is it reading or writing? | `report-kill` |
| `updatePlayerData` | camelCase, too generic | `award-xp` |
| `do-thing` | Meaningless | Describe the action |
| `api-v2-save` | Version info doesn't belong in slugs | `save-loadout` |
### Step IDs
Use short, descriptive `snake_case` IDs. Other steps reference these with `{{stepId.field}}`, so clarity matters.
```yml
id: save
```
Avoid generic IDs like `step1`, `data`, or `result`. When you read the pipeline later, `{{player.gold}}` is immediately clear while `{{step1.gold}}` is not.
Use the `as` alias when a step ID would be awkward but the reference name should be clean:
```yml
id: read_player_data
as: player
type: read
collection: player_data
key: "{{playerKey}}"
```
## Identity Fields
Treat `steamId` and `playerKey` as immutable runtime identity fields, not caller-controlled parameters.
- Use `{{playerKey}}` for collection record keys. It automatically tracks the project's player-key mode (`steamId` alone or `steamId_saveId`).
- Use `{{steamId}}` when you need the caller's identity as data -- for example logs, webhook fields, or third-party API calls.
- Do not try to pass `steamId` or `playerKey` through route params to nested flows. Internal `run` calls inherit them from the caller and the runtime protects them from being overwritten.
## Input Schema Design
### Require What You Need
Only mark fields as `required` if the endpoint cannot function without them. Optional fields should have sensible defaults in your logic.
```yml
input:
type: object
properties:
item_id:
type: string
quantity:
type: number
required:
- item_id
```
Here `quantity` is optional -- if not sent, you can default to `1` in a transform step.
### Use Specific Types
Use `type: string` for IDs and names, `type: number` for quantities and amounts. Input validation catches type mismatches before the pipeline runs, which gives clearer errors than a failed condition step.
### Keep Inputs Minimal
The endpoint receives events from the game, not full data objects. Send only identifiers and action parameters -- the endpoint reads everything else from collections and Game Values.
```yml
# WRONG -- game sends the price (client can be tampered with)
item_id: iron_sword
price: 100
# CORRECT -- endpoint looks up the price server-side
item_id: iron_sword
```
This is a security boundary. Never trust values the game client could manipulate. Look up prices, XP amounts, and requirements from Game Values or collections inside the endpoint.
## Pipeline Structure
### Standard Patterns
Most endpoints follow one of these patterns:
**Read-Check-Write** -- the most common pattern for game actions:
```yml
steps:
- id: player
type: read
collection: player_data
key: "{{playerKey}}"
- id: item
type: lookup
source: values
table: items
where:
field: item_id
op: ==
value: "{{input.item_id}}"
- id: can_afford
type: condition
check:
left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
onFail:
status: 403
error: NOT_ENOUGH_GOLD
message: You need {{item.cost}} gold.
- id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: gold
value: "{{-item.cost}}"
- op: push
path: inventory
value:
item_id: "{{input.item_id}}"
```
**Read-Only** -- for fetching computed data:
```yml
steps:
- id: player
type: read
collection: player_data
key: "{{playerKey}}"
- id: level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
response:
xp: "{{player.xp}}"
level: "{{level}}"
gold: "{{player.gold}}"
```
**Event Tracking** -- fire-and-forget stat recording:
```yml
steps:
- id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: stats.deaths
value: 1
response:
status: 200
body:
ok: true
```
### Step Ordering Rules
1. **Reads and lookups first** -- gather all the data the pipeline needs
2. **Transforms next** -- compute derived values from the data you read
3. **Conditions after transforms** -- validate using computed values
4. **Writes last** -- only after all checks pass
This order is not enforced by the system, but following it makes pipelines predictable and easy to debug. Mixing reads and writes makes it harder to reason about what data is available at each step.
### Keep Pipelines Short
A well-designed endpoint has 3-7 steps for simple moments, but larger server-authoritative gameplay endpoints can use up to 500 steps. If an endpoint is growing hard to read, consider:
- **Extracting to reusable logic** -- shared validation or side-effect orchestration should move into a workflow or an internal endpoint that multiple public endpoints can `run`
- **Splitting into separate endpoints** -- if the endpoint does two unrelated things, make them two endpoints
- **Simplifying Game Values** -- if you need multiple lookups, consider restructuring your tables
### Prefer explicit endpoint primitives over boilerplate
Use the dedicated endpoint features when they preserve the domain contract more clearly than repeated low-level steps:
- Use `required: true` + `onMissing` on `lookup`, `read`, `filter`, and `random_select` when a missing row is invalid input. Do not add a separate assert just to check `step != null`.
- Use `check.expression` on `assert` or `condition` for one-off math predicates. Do not create throwaway transform steps that are only consumed once by a guard.
- Use `compute` with `output: object` when building a structured payload that will be written and returned. Keep intermediate values inside that object unless later steps need them independently.
- Use write `op: merge` to write a previously built object in one operation instead of dozens of `set` ops.
- Use a `block` step with `when` when several child steps share the same condition.
- Use endpoint `let` aliases for repeated long paths, but keep aliases meaningful and explicit. Avoid aliases that hide security or ownership boundaries.
These features are optional and backward-compatible. Existing `field`/`op`/`value` checks, regular response `body` templates, and individual write ops remain valid.
## Error Handling
### Use Descriptive Error Codes
Error codes should be uppercase `SNAKE_CASE` and describe what went wrong, not what the system did.
```yml
# GOOD -- tells the game what happened
- status: 403
error: NOT_ENOUGH_GOLD
message: You need 500 gold. You have 120.
- status: 409
error: ALREADY_OWNED
message: You already own this vehicle.
- status: 403
error: LEVEL_TOO_LOW
message: Requires level 10. You are level 3.
# BAD -- vague, unhelpful to game code
- status: 400
error: FAILED
message: Cannot complete.
- status: 403
error: ERROR
message: Something went wrong.
```
### Include Context in Messages
Use template variables in error messages so the player (or developer debugging) sees actual values:
```yml
onFail:
status: 403
error: XP_TOO_LOW
message: You need {{node.xp_required}} XP to mine this node. You have {{player.xp}}.
```
### Use Consistent Status Codes
| Status | When to Use |
|--------|-------------|
| `200` | Success |
| `400` | Bad input (missing fields, wrong types) |
| `403` | Player doesn't meet requirements (not enough gold, level too low) |
| `404` | Referenced item/record not found |
| `409` | Conflict (already owned, duplicate action) |
| `429` | Rate limited |
Pick one status code per error type and stick with it across all endpoints. Your game code can switch on error codes, not status codes, but consistent statuses help with monitoring and debugging.
### Handle Error Codes in Game Code
Design your error codes so the game client can react to them programmatically:
```csharp
var data = await NetworkStorage.CallEndpoint( "purchase-item", new {
item_id = selectedItem.Id
} );
if ( data.HasValue )
{
Inventory.Refresh();
Notify( $"Purchased {selectedItem.Name}!" );
}
else
{
switch ( data.Error )
{
case "NOT_ENOUGH_GOLD":
ShowShopPrompt( "You need more gold!" );
break;
case "ALREADY_OWNED":
ShowNotice( "You already own this item." );
break;
case "LEVEL_TOO_LOW":
ShowNotice( data.Message ); // includes the actual level requirement
break;
default:
Log.Warning( $"Unexpected: {data.Error} - {data.Message}" );
break;
}
}
```
## Response Design
### Return What the Client Needs
Include computed values the client will display. Don't make the client do math that the server already did.
```yml
response:
xp: "{{player.xp}}"
level: "{{current_level}}"
xpToNextLevel: "{{xp_remaining}}"
gold: "{{player.gold}}"
```
### Don't Return Sensitive Data
Never include secret keys, internal IDs, or other players' data in the response. The response goes directly to the game client.
```yml
# WRONG -- leaks internal data
response:
internalId: "{{player._id}}"
secretKey: "{{apiKey}}"
# CORRECT -- only player-facing values
response:
xp: "{{player.xp}}"
level: "{{current_level}}"
```
### Keep Responses Flat
Prefer a flat response structure over deeply nested objects. The game client parses this JSON, so simpler is better.
```yml
# Preferred
response:
xp: "{{player.xp}}"
gold: "{{player.gold}}"
itemName: "{{item.name}}"
```
## Security
### Never Trust Client Input for Values
The game client should send identifiers and actions -- never amounts, prices, or rewards. All values should come from Game Values or collections.
```yml
# WRONG -- client controls how much XP they get
op: inc
path: xp
value: "{{input.xp_amount}}"
# CORRECT -- XP comes from server-side Game Values
op: inc
path: xp
value: "{{values.combat.xp_per_kill}}"
```
### Use Conditions to Prevent Exploits
Check requirements before writing. Common checks:
- **Ownership**: Does the player already own this item?
- **Affordability**: Can the player afford this purchase?
- **Eligibility**: Does the player meet level/XP requirements?
- **Cooldowns**: Has enough time passed since the last action?
```yml
id: cooldown_check
type: condition
check:
left: "{{elapsed}}"
op: '>='
right: "{{values.limits.action_cooldown_ms}}"
onFail:
status: 429
error: COOLDOWN_ACTIVE
message: Wait before performing this action again.
```
### Use s&box Auth
Enable s&box auth on your project so endpoints verify the player's Steam identity. Without it, any Steam ID can be passed in the request. See the [s&box Auth](/wiki/network-storage-v3/sbox-auth) documentation for setup.
### Ledger Fields for Valuable Data
Mark currency and important numeric fields with `_ledger: true` in your collection schema. This creates an immutable audit trail that cannot be tampered with. Always provide `source` and `reason` on write ops for ledgered fields.
## Performance
### Minimize Read Steps
Each read/lookup/filter/lookup_many/random_select step is a read-like operation. You have a maximum of 25 across the entire pipeline (including workflows). Design your collections and Game Values so you can get all the data you need in a small number of reads.
**Common patterns:**
- Store related data together in one collection record instead of spreading across multiple collections
- Use Game Values for static configuration data (prices, XP tables, item definitions) -- these are cached and fast
- Use `filter` instead of multiple `lookup` calls when you need several rows from the same table
### Use Reusable Flows for Shared Logic
If multiple endpoints check the same conditions (ownership, balance, eligibility), extract that logic into a reusable flow. Prefer a workflow when the contract is naturally `params` + `returns`; prefer an internal endpoint when endpoint-style `input` / `response` metadata makes the call site clearer.
```yml
id: validate
type: workflow
workflow: validate_purchase
params:
player: "{{player}}"
item_id: "{{input.item_id}}"
```
### Batch Write Operations
Combine related writes into a single write step. Each write step is one atomic operation -- multiple ops in the same step are efficient.
```yml
# GOOD -- one write step with multiple ops
- id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: xp
value: "{{xp_reward}}"
- op: inc
path: gold
value: "{{gold_reward}}"
- op: inc
path: stats.kills
value: 1
# AVOID -- three separate write steps to the same record
- id: save_xp
type: write
# ...
- id: save_gold
type: write
# ...
- id: save_kills
type: write
# ...
```
If several route branches all write to the same record, keep one write step and put `when` on the individual ops instead of repeating `condition` + `write` pairs:
```yml
id: save
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: storage.ore
value: 1
when:
field: route.kind
op: ==
value: storage
- op: inc
path: processor.input
value: 1
when:
field: route.kind
op: ==
value: processor
```
## Checklist
Before deploying an endpoint, verify:
- [ ] Slug follows `kebab-case` naming with a clear action verb
- [ ] Input schema only includes fields the game client sends
- [ ] All prices, rewards, and requirements come from Game Values or collections -- not from client input
- [ ] Conditions validate requirements before writes
- [ ] Error codes are descriptive `SNAKE_CASE` with helpful messages
- [ ] Response includes only player-facing data
- [ ] Reads are minimized (1-3 per pipeline)
- [ ] Shared validation logic is extracted into a reusable flow (workflow or internal endpoint)
- [ ] Ledger fields have `source` and `reason` metadata
- [ ] s&box auth is enabled (unless intentionally disabled for testing)
## Route Loops and Expected Rejections
Use explicit condition routes when the true and false outcomes both have meaningful behavior. Keep hard requirements as `reject` routes with 4xx statuses so game-logic failures do not look like server incidents.
For intentional polling, every backward `goto` must be bounded. Pair loops with `retry.maxAttempts`, `retry.maxElapsedMs`, and a short `sleep` step or `retry.sleepMs`. Do not create tight CPU loops, and remember that reads, writes, route transitions, sleeps, and wall time all share the same execution budget.
```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 }
```
Know which limit you are expressing:
- `retry.maxAttempts` is the concrete runtime revisit limit for that step and can be at most **10**.
- Without retry bounds, the runtime stops a step after **20** visits.
- `route.maxVisits` / `route.maxTransitions` are validation hints for backward or recursive branches; they do not replace retry bounds.
- Each sleep is capped at **1000ms**, the whole execution at **30000ms**, nested flow depth at **4**, and total route transitions at **1000**.
If a polling loop must observe fresh storage state, configure the read-like step with `refresh: true` or `refreshCache: true`; otherwise the per-execution read cache may return the previous value.