Wire Protocol
This document specifies the HTTP and data-format contract between a SkyState client and the SkyState API. Use this reference when implementing a SkyState client in a language or runtime that does not yet have an official SDK.
The canonical implementation is @skystate/core. Where this specification is ambiguous, the @skystate/core source is the authoritative reference.
Terminology
| Term | Meaning |
|---|---|
| account slug | URL-safe identifier for the account that owns the project (my-company) |
| project slug | URL-safe identifier for a project within an account (my-app) |
| env slug | One of the three canonical environment names: development, staging, production |
| dev key | A project-scoped API key that authorizes writes to the development environment only |
| version | Semantic version triple {major}.{minor}.{patch} — used for optimistic concurrency |
| config | An arbitrary JSON object whose structure is defined by the application |
| envelope | The response wrapper that includes the version, timestamp, and config payload |
Configuration
A client implementation must accept at minimum the following inputs at construction time:
| Parameter | Type | Required | Description |
|---|---|---|---|
apiUrl | string | Yes | Base URL of the SkyState API, e.g. https://api.skystate.io. Strip any trailing /. |
accountSlug | string | Yes | The account slug. |
projectSlug | string | Yes | The project slug. |
resolveEnvironment | function / callback | Yes | A function called once at startup that returns the resolved environment slug. Must return one of development, staging, or production. |
devKey | string | No | Project-scoped dev API key. When present and the resolved environment is development, enables authenticated writes and auto-provisioning. |
initialConfig | JSON object | No | Optional config to seed the local cache before the first HTTP fetch completes. Useful for SSR scenarios. |
clientHeader | string | No | Custom value for the X-SkyState-Client header. Defaults to the SDK name and version string, e.g. @skystate/core/0.1.0. |
Environment Resolution
Environment resolution must follow these rules exactly:
- Call
resolveEnvironment()once, immediately at client startup, inside a try/catch. - If the call throws, hard-fail with a descriptive error — do not silently default.
- Validate the returned value against the set
{ "development", "staging", "production" }. - If the value is not in that set, hard-fail with a descriptive error.
- Cache the resolved value — do not call
resolveEnvironment()again.
Why hard-fail: A client silently defaulting to the wrong environment (e.g. production in a dev build) can cause data corruption or expose draft configuration. Fail loudly so the misconfiguration is caught immediately.
Platform-idiomatic equivalents for NODE_ENV:
| Platform | Convention | Typical mapping |
|---|---|---|
| Node.js / webpack | process.env.NODE_ENV | "development" → "development", "production" → "production" |
| Vite / Rollup / esbuild | import.meta.env.MODE | "development" → "development", "production" → "production" |
| .NET | ASPNETCORE_ENVIRONMENT | "Development" → "development", "Production" → "production" |
| Python | User-provided env var | Map to one of the three slugs |
| Godot | OS.is_debug_build() | true → "development", false → "production" |
The resolver must map whatever the platform provides to one of the three canonical slug strings. The SkyState API is case-sensitive: development, not Development.
env_map
Clients may optionally accept an env_map parameter — a dictionary mapping arbitrary environment-variable values to canonical SkyState environment slugs:
{
"test": "development",
"preview": "staging"
}When provided, the resolved value from resolveEnvironment() is looked up in the map before validation. This allows applications to reuse non-standard NODE_ENV values without writing a custom resolver.
Authentication
Reading config (public endpoint)
The public config GET endpoint requires no authentication. Do not attach an Authorization header for reads.
Writing config (dev endpoint)
Writes to the development environment require a project-scoped dev API key, passed as:
Authorization: DevKey <dev_key_value>Dev keys are restricted to the development environment on the server. A request to PATCH staging or production with a dev key returns 403 Forbidden.
Dashboard / admin operations
Dashboard and admin operations use JWT bearer tokens issued by the SkyState auth flow. These are outside the scope of client SDK implementations. SDK clients use dev keys for development writes and the public endpoint for all reads.
HTTP Endpoints
All endpoints are under the base URL you configured at construction.
Read project config — GET /public/{accountSlug}/{projectSlug}/config/{envSlug}
Fetches the latest config for an environment. No authentication required.
Request headers:
| Header | Required | Value |
|---|---|---|
Accept | Recommended | application/json |
X-SkyState-Client | Recommended | SDK name and version, e.g. @skystate/core/0.1.0 |
Successful response — 200 OK:
{
"version": {
"major": 1,
"minor": 3,
"patch": 0
},
"lastModified": "2024-11-15T10:32:00.000Z",
"config": {
"banner": {
"enabled": true,
"text": "Hello!"
},
"features": {
"maxItems": 5
}
}
}The version object is a semantic version triple. The canonical version string is "{major}.{minor}.{patch}", e.g. "1.3.0". Clients must track this string and pass it in the If-Match header on all PATCH requests.
The lastModified field is an ISO 8601 timestamp in UTC.
The config field is an arbitrary JSON object. Its structure is entirely application-defined. Clients must not assume any particular shape.
Response headers:
| Header | Description |
|---|---|
Cache-Control: public, max-age=N | Server-controlled refresh interval in seconds. See Polling Schedule. |
X-RateLimit-Limit | Request limit for the current billing period (omitted for unlimited tiers) |
X-RateLimit-Remaining | Remaining requests in the current billing period |
X-RateLimit-Reset | Unix timestamp (seconds) when the rate limit counter resets |
Error responses:
| Status | Condition |
|---|---|
400 Bad Request | Account, project, or env slug contains invalid characters (not lowercase alphanumeric or hyphen) |
404 Not Found | Account, project, or environment does not exist |
429 Too Many Requests | Monthly API request limit exceeded. Retry-After header contains seconds until reset. |
Rate limit exceeded — 429 response body:
{
"code": "RATE_LIMIT_EXCEEDED",
"message": "Monthly API request limit exceeded. Upgrade your plan for higher limits.",
"limit": 10000,
"current": 10001,
"resetAt": "2024-12-01T00:00:00.000Z",
"upgradeUrl": "/upgrade"
}Write project config — PATCH /dev/{accountSlug}/{projectSlug}/config/{envSlug}
Applies a sequence of JSON Patch operations to the current config. Restricted to the development environment. Requires a dev API key.
Request headers:
| Header | Required | Value |
|---|---|---|
Content-Type | Yes | application/json |
Accept | Recommended | application/json |
Authorization | Yes | DevKey <dev_key_value> |
If-Match | Yes | "<version_string>" — the version string from your last successful GET, wrapped in double quotes, e.g. "1.3.0" |
X-SkyState-Client | Recommended | SDK name and version |
Request body:
A JSON array of patch operations. See JSON Patch Operations for the full specification.
[
{ "op": "add", "path": "/banner/enabled", "value": true },
{ "op": "replace", "path": "/features/maxItems", "value": 10 }
]Successful response — 201 Created:
{
"remoteConfigId": "550e8400-e29b-41d4-a716-446655440000",
"version": "1.4.0"
}The response includes the new version string. Update your local version tracking from this value.
Response headers on success:
ETag: "1.4.0"Error responses:
| Status | Condition |
|---|---|
400 Bad Request | Missing If-Match header, or If-Match: * (wildcard not supported), or invalid patch operation |
401 Unauthorized | Missing or invalid dev key |
403 Forbidden | Dev key present but environment is not development |
404 Not Found | Project or environment does not exist |
409 Conflict | Version mismatch — another client updated the config since your last GET. See Conflict Handling. |
402 Payment Required | Account has exceeded its plan storage limit |
Conflict response — 409 body:
{
"code": "VERSION_CONFLICT",
"message": "Config was modified since your last read. Re-fetch and retry.",
"currentVersion": "1.4.0"
}The currentVersion field contains the version that was found on the server. The response also includes an ETag: "1.4.0" header.
JSON Patch Operations
The PATCH body is a JSON array conforming to RFC 6902 with two SkyState-specific extensions.
Standard RFC 6902 operations
op | Effect |
|---|---|
add | Add a value at the path. If the path already exists, the value is replaced. If an intermediate key is missing, the operation fails. |
remove | Remove the key at the path. Fails if the path does not exist. |
replace | Replace an existing value. Fails if the path does not exist. |
move | Move the value at from to path. |
copy | Copy the value at from to path. |
test | Assert that the value at path equals value. If the test fails, the entire patch is rejected. |
SkyState-specific operations
These two operations are extensions beyond RFC 6902. Standard RFC 6902 clients must map these to add/replace before sending, or implement them natively if the server supports them.
op | Effect |
|---|---|
increment | Increment the numeric value at path by 1. Atomic on the server — safe for concurrent counters. |
decrement | Decrement the numeric value at path by 1. Atomic on the server. |
Atomicity
The entire patch array is applied atomically. If any operation fails (including a test assertion), the entire patch is rejected and the config is unchanged. No partial application occurs.
Path syntax
RFC 6902 paths use / as a separator and begin with /, e.g. /banner/enabled. This differs from the dot-notation used in the client SDK for subscriptions. Clients must convert dot-paths to slash-paths before constructing patch operations:
| Dot-path (SDK) | RFC 6902 path (wire) |
|---|---|
banner.enabled | /banner/enabled |
features.maxItems | /features/maxItems |
myKey | /myKey |
Sentinel / modifier pattern
The increment and decrement operations are server-side sentinels. Client code may express them as placeholder objects and resolve them to wire operations just before sending:
// Application code uses a sentinel:
const ops = [{ op: 'increment', path: '/pageViews' }];
// SDK resolves sentinels to wire format before PATCH:
// (In this case no transformation is needed since the server accepts 'increment' directly)When implementing a client SDK, apply sentinel resolution as the last step before serializing the request body. This keeps application code free of wire-format concerns.
Polling Schedule
The server controls how often the client re-fetches config by setting Cache-Control: max-age=N on the GET response. Clients must:
- Parse the
max-agedirective from theCache-Controlheader after each successful GET. - Schedule the next GET after
max-ageseconds. - Cancel any pending timer when the client is disposed.
The max-age values by tier and environment are:
| Tier | development | staging | production |
|---|---|---|---|
| Free | 10 s | — | 900 s (15 min) |
| Hobby | 10 s | 10 s | 300 s (5 min) |
| Pro | 10 s | 10 s | 60 s (1 min) |
Development environments always get 10 seconds to support fast iteration. Production environments get longer intervals on lower tiers.
Tab / window visibility
In browser environments, clients should re-fetch when the tab or window becomes visible after being hidden, if the current cached value has expired (i.e. elapsed >= max_age). This keeps configs fresh without polling while the tab is hidden.
Implementation:
on visibilitychange:
if document.visibilityState == "visible":
elapsed = now() - last_fetched_at
if max_age > 0 and elapsed >= max_age:
cancel_pending_timer()
fetch_now()In environments without a visibility API (Node.js, native apps), omit this step.
Conflict Handling
The PATCH endpoint uses optimistic concurrency. The client sends the version it last saw in If-Match. If another write has occurred since, the server returns 409 Conflict.
Required client behaviour on 409:
- Do not retry the patch automatically.
- Trigger a fresh GET to retrieve the current config and version.
- If the missing keys still do not exist (auto-provisioning scenario), re-issue the PATCH with the new version.
This flow ensures that two concurrent clients that both try to auto-provision the same key do not corrupt each other's state. The second client's PATCH simply re-runs after re-fetching, and since the key now exists (added by the first client), the add operation either succeeds or is idempotent depending on the patch operation used.
Error Handling Reference
| Error condition | HTTP status | Client action |
|---|---|---|
| Invalid slug characters | 400 | Hard-fail with descriptive error — misconfiguration |
| Project or environment not found | 404 | Hard-fail with descriptive error — misconfiguration |
Missing If-Match header | 400 | Bug in client — always include If-Match on PATCH |
Wildcard If-Match: * | 400 | Bug in client — always provide explicit version |
| Invalid patch operation | 400 | Hard-fail — check patch construction logic |
| Auth failure (no/bad dev key) | 401 | Hard-fail — check dev key configuration |
| Wrong environment for dev key | 403 | Hard-fail — dev keys are development-only |
| Version conflict | 409 | Re-fetch, then optionally retry the patch |
| Plan storage limit | 402 | Surface to developer — upgrade plan or reduce config size |
| Rate limit exceeded | 429 | Log warning, skip fetch, retry after Retry-After seconds |
| Network failure | N/A | Call error handler — do not crash. Retain stale cache. |
| Non-2xx (other) | varies | Call error handler with the HTTP status and body |
Dev-mode Auto-provisioning
When devKey is set and the resolved environment is development, the client must implement auto-provisioning of missing keys. This is the mechanism that lets developers write application code referencing config keys before those keys exist in SkyState.
Trigger conditions
Auto-provisioning is triggered when all of the following are true:
devKeyis set.- Resolved environment is
development. - The application reads a config key (via
getor equivalent). - The key does not exist in the current config (value is
undefined/null/ absent). - The application provided a default / fallback value for the key.
- The initial GET has completed (do not trigger during the initial load).
Flow
1. Application reads key "banner.enabled" with fallback `true`
2. Key is missing in local cache
3. Enqueue ("banner.enabled", true) in pending batch
4. After debounce delay (≥ 50ms), flush batch as PATCH:
PATCH /dev/{accountSlug}/{projectSlug}/config/development
If-Match: "1.3.0"
[{ "op": "add", "path": "/banner/enabled", "value": true }]
5a. If 201: re-fetch GET to sync the new version
5b. If 409: re-fetch GET, do NOT re-issue the patch
(another client already created the key — their value wins)Batching and debounce
Multiple missing keys read in the same render cycle or within a short window must be batched into a single PATCH request. A debounce of 50ms is sufficient. Each key should be attempted at most once per client instance lifetime (tracked in a "attempted" set) to prevent infinite retry loops if the PATCH keeps failing.
Hard rules
- Auto-provisioning only applies to
addoperations on missing top-level or nested keys. - Auto-provisioning never runs outside
development. - Auto-provisioning never runs without a
devKey. - Auto-provisioning does not overwrite existing keys — use
add, notreplace. If the key exists (because another client created it), the operation is a no-op or a conflict that resolves to a re-fetch. - Log each auto-provisioned key to the console so developers know what is being created.
Singleton Pattern
Client instances must be keyed by the tuple (apiUrl, accountSlug, projectSlug, resolvedEnvironment). If the application requests a client with the same parameters, return the existing instance rather than creating a new one.
Maintain a module-level or process-level registry for this purpose:
registry key: "${apiUrl}|${accountSlug}|${projectSlug}|${environmentSlug}"When the client is disposed, remove it from the registry. The next call with the same parameters creates a fresh instance.
This ensures that all consumers reading from the same project and environment share a single HTTP connection and a single cache.
Platform Notes
Godot / GDScript
- GDScript does not have
async/awaitin the same form as JavaScript. Useawaitwith signal-based HTTP requests (HTTPRequest). - Environment resolution:
OS.is_debug_build()→"development", else"production". - No tab visibility API — omit the visibility re-fetch optimization.
- Implement the client as an
autoloadsingleton node so all scenes share the same instance. - Store the registry key in a static variable on the autoload class.
.NET
- Use
HttpClientwith a shared instance (registered via DI as a singleton) — do not instantiate per request. resolveEnvironmentmapsIHostEnvironment.EnvironmentNameto the canonical slug:"Development"→"development","Staging"→"staging","Production"→"production".- Use
CancellationTokenfor disposal and request cancellation. - Implement the store as a hosted service or a lazily-initialized singleton in DI.
- Reactive subscriptions can use
IObservable<T>or a custom event pattern.
Python
- Use
httpxoraiohttpfor async HTTP. Do not userequestsin an async context. - Environment resolution: read
os.environ.get("SKYSTATE_ENV")or map from a framework-specific variable. - Use
asyncio.get_event_loop().call_later(delay, callback)for the polling timer. - Implement auto-provisioning using
asyncio.Lockto prevent concurrent PATCH races on the same key. - The singleton registry can be a module-level
dictprotected by athreading.Lockfor thread safety in sync contexts.