Skip to content

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

TermMeaning
account slugURL-safe identifier for the account that owns the project (my-company)
project slugURL-safe identifier for a project within an account (my-app)
env slugOne of the three canonical environment names: development, staging, production
dev keyA project-scoped API key that authorizes writes to the development environment only
versionSemantic version triple {major}.{minor}.{patch} — used for optimistic concurrency
configAn arbitrary JSON object whose structure is defined by the application
envelopeThe response wrapper that includes the version, timestamp, and config payload

Configuration

A client implementation must accept at minimum the following inputs at construction time:

ParameterTypeRequiredDescription
apiUrlstringYesBase URL of the SkyState API, e.g. https://api.skystate.io. Strip any trailing /.
accountSlugstringYesThe account slug.
projectSlugstringYesThe project slug.
resolveEnvironmentfunction / callbackYesA function called once at startup that returns the resolved environment slug. Must return one of development, staging, or production.
devKeystringNoProject-scoped dev API key. When present and the resolved environment is development, enables authenticated writes and auto-provisioning.
initialConfigJSON objectNoOptional config to seed the local cache before the first HTTP fetch completes. Useful for SSR scenarios.
clientHeaderstringNoCustom 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:

  1. Call resolveEnvironment() once, immediately at client startup, inside a try/catch.
  2. If the call throws, hard-fail with a descriptive error — do not silently default.
  3. Validate the returned value against the set { "development", "staging", "production" }.
  4. If the value is not in that set, hard-fail with a descriptive error.
  5. 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:

PlatformConventionTypical mapping
Node.js / webpackprocess.env.NODE_ENV"development""development", "production""production"
Vite / Rollup / esbuildimport.meta.env.MODE"development""development", "production""production"
.NETASPNETCORE_ENVIRONMENT"Development""development", "Production""production"
PythonUser-provided env varMap to one of the three slugs
GodotOS.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:

json
{
  "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:

HeaderRequiredValue
AcceptRecommendedapplication/json
X-SkyState-ClientRecommendedSDK name and version, e.g. @skystate/core/0.1.0

Successful response — 200 OK:

json
{
  "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:

HeaderDescription
Cache-Control: public, max-age=NServer-controlled refresh interval in seconds. See Polling Schedule.
X-RateLimit-LimitRequest limit for the current billing period (omitted for unlimited tiers)
X-RateLimit-RemainingRemaining requests in the current billing period
X-RateLimit-ResetUnix timestamp (seconds) when the rate limit counter resets

Error responses:

StatusCondition
400 Bad RequestAccount, project, or env slug contains invalid characters (not lowercase alphanumeric or hyphen)
404 Not FoundAccount, project, or environment does not exist
429 Too Many RequestsMonthly API request limit exceeded. Retry-After header contains seconds until reset.

Rate limit exceeded — 429 response body:

json
{
  "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:

HeaderRequiredValue
Content-TypeYesapplication/json
AcceptRecommendedapplication/json
AuthorizationYesDevKey <dev_key_value>
If-MatchYes"<version_string>" — the version string from your last successful GET, wrapped in double quotes, e.g. "1.3.0"
X-SkyState-ClientRecommendedSDK name and version

Request body:

A JSON array of patch operations. See JSON Patch Operations for the full specification.

json
[
  { "op": "add", "path": "/banner/enabled", "value": true },
  { "op": "replace", "path": "/features/maxItems", "value": 10 }
]

Successful response — 201 Created:

json
{
  "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:

StatusCondition
400 Bad RequestMissing If-Match header, or If-Match: * (wildcard not supported), or invalid patch operation
401 UnauthorizedMissing or invalid dev key
403 ForbiddenDev key present but environment is not development
404 Not FoundProject or environment does not exist
409 ConflictVersion mismatch — another client updated the config since your last GET. See Conflict Handling.
402 Payment RequiredAccount has exceeded its plan storage limit

Conflict response — 409 body:

json
{
  "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

opEffect
addAdd a value at the path. If the path already exists, the value is replaced. If an intermediate key is missing, the operation fails.
removeRemove the key at the path. Fails if the path does not exist.
replaceReplace an existing value. Fails if the path does not exist.
moveMove the value at from to path.
copyCopy the value at from to path.
testAssert 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.

opEffect
incrementIncrement the numeric value at path by 1. Atomic on the server — safe for concurrent counters.
decrementDecrement 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:

typescript
// 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:

  1. Parse the max-age directive from the Cache-Control header after each successful GET.
  2. Schedule the next GET after max-age seconds.
  3. Cancel any pending timer when the client is disposed.

The max-age values by tier and environment are:

Tierdevelopmentstagingproduction
Free10 s900 s (15 min)
Hobby10 s10 s300 s (5 min)
Pro10 s10 s60 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:

  1. Do not retry the patch automatically.
  2. Trigger a fresh GET to retrieve the current config and version.
  3. 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 conditionHTTP statusClient action
Invalid slug characters400Hard-fail with descriptive error — misconfiguration
Project or environment not found404Hard-fail with descriptive error — misconfiguration
Missing If-Match header400Bug in client — always include If-Match on PATCH
Wildcard If-Match: *400Bug in client — always provide explicit version
Invalid patch operation400Hard-fail — check patch construction logic
Auth failure (no/bad dev key)401Hard-fail — check dev key configuration
Wrong environment for dev key403Hard-fail — dev keys are development-only
Version conflict409Re-fetch, then optionally retry the patch
Plan storage limit402Surface to developer — upgrade plan or reduce config size
Rate limit exceeded429Log warning, skip fetch, retry after Retry-After seconds
Network failureN/ACall error handler — do not crash. Retain stale cache.
Non-2xx (other)variesCall 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:

  1. devKey is set.
  2. Resolved environment is development.
  3. The application reads a config key (via get or equivalent).
  4. The key does not exist in the current config (value is undefined / null / absent).
  5. The application provided a default / fallback value for the key.
  6. 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 add operations 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, not replace. 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/await in the same form as JavaScript. Use await with 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 autoload singleton node so all scenes share the same instance.
  • Store the registry key in a static variable on the autoload class.

.NET

  • Use HttpClient with a shared instance (registered via DI as a singleton) — do not instantiate per request.
  • resolveEnvironment maps IHostEnvironment.EnvironmentName to the canonical slug: "Development""development", "Staging""staging", "Production""production".
  • Use CancellationToken for 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 httpx or aiohttp for async HTTP. Do not use requests in 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.Lock to prevent concurrent PATCH races on the same key.
  • The singleton registry can be a module-level dict protected by a threading.Lock for thread safety in sync contexts.

Built with VitePress