API protocol
APIServer carries explicitly registered node operations over an authenticated, reliable byte stream. This page defines the shared wire format. The APIServer node reference owns gateway settings and permission configuration; each target node's reference owns its operation names, request bodies, responses, and state-change guarantees.
For a runnable client, start with the Python client example.
For account commands, use the administration CLI.
They share the standard-library transport module
ww_api.py;
ww_api_cli.py shares connection options, bounded token loading and uncertain-write
advice. The transport module remains independent of argparse; AuthenticationServer
body/schema validation stays in ww_admin.py. Copying the generic example requires
its sibling client/ directory too. It writes decoded result bytes directly to stdout.
For node implementation, see Implementing node API operations.
Transport and framing
Supported chains deliver an ordered reliable stream:
TcpListener -> APIServer
TcpListener -> HttpServer -> APIServer
UdpListener -> TcpOverUdpServer -> APIServer
APIServer opens no socket and adds no transport encryption. Remote access needs
an authenticated encrypted transport with peer identity verification. API tokens
do not prevent unauthenticated resource consumption; restrict reachability or
authenticate peers in the transport. Input bounds do not eliminate connection
floods. Direct UdpListener -> APIServer is
unsupported: a length prefix does not repair datagram loss or reordering.
TcpOverUdpServer reconstructs the reliable stream before APIServer reads it.
See the transport examples for complete
HTTP and reliable-UDP arrangements.
Every request and response has the same framing:
4-byte unsigned big-endian length | exactly length UTF-8 JSON bytes
The length excludes the prefix and must be nonzero and within the configured envelope limit. There is no newline or terminating NUL. A socket read, tunnel payload callback, HTTP chunk, or UDP packet is not a frame boundary: a prefix or body can span reads, and one read can contain several frames.
An HTTP composition carries these framed bytes in the HTTP body. The caller dechunks the response before reading the API prefix and uses that length to recognize a complete reply, without waiting for HTTP EOF or its final chunk. HttpServer handles its configured method and path. APIServer receives only body bytes; this interface adds no REST routing, header authentication, or per-operation HTTP status mapping.
Request envelope
{
"id": "request-42",
"auth": {"client": "monitor", "token": "<configured token>"},
"selector": {"config": "edge-europe", "node": "auth-client"},
"operation": "status",
"content_type": "application/json",
"body_encoding": "utf8",
"body": "{\"include_accounting\":true}"
}
All shown fields are required except selector.config.
| Field | Meaning |
|---|---|
id | Nonempty correlation string, at most 64 UTF-8 bytes. It is echoed in the response and does not deduplicate requests. |
auth.client | Configured API client name, at most 256 UTF-8 bytes. |
auth.token | The client's 32-byte static token, encoded as exactly 64 hexadecimal characters. |
selector.config | Optional exact logical configuration name, at most 256 UTF-8 bytes. |
selector.node | Exact public top-level node name, at most 256 UTF-8 bytes. |
operation | Exact registered operation name, at most 64 UTF-8 bytes. |
content_type | Content type of the decoded node body, at most 128 UTF-8 bytes; it must match the operation's declared request type. |
body_encoding | utf8 or base64. |
body | A JSON string containing the encoded node request, even when that request is itself JSON. |
Metadata names are case-sensitive. The outer parser rejects unknown or duplicate keys, wrong types, malformed UTF-8, trailing non-whitespace input, and decoded NUL in metadata or UTF-8 bodies. It bounds the envelope's nesting and values before allocating a JSON tree. These outer-envelope rules do not define the target node's body schema. Runtime lexical rejections return fixed protocol errors without configuration-parser stderr diagnostics; startup/configuration parsing retains its diagnostics.
Authentication, target selection, and permission
Every envelope authenticates independently. An API token is separate from a
traffic user's password and from AuthenticationClient's upstream producer
credentials. Unknown client names and incorrect tokens both return
unauthorized. Authentication happens before target lookup.
selector.config names a loaded configuration's logical name; it is not a
filename and cannot load a configuration. When an APIServer is configured, all
loaded configurations must have nonempty unique logical names. Generated
internal child nodes are not public API targets.
Omitting selector.config works only when the exact node name occurs once
across all declared top-level nodes in all loaded configurations. Count
nodes even if they expose no API or the caller cannot access them. Registration
and permission filtering cannot make an ambiguous name unique; supply the
configuration name to resolve ambiguous_target.
After resolving the target, APIServer checks the client's exact allowlist entry
for that configuration, node, and operation. There are no wildcard permissions
or permissions implied by a different operation. A read-only descriptor does not
make an operation public. Startup validates allowlist targets and registered
operations and prepares immutable descriptors reused after registry freeze. A
missing exact permission returns forbidden. An old TunnelApi symbol alone
exposes nothing through this gateway. read_only is descriptive metadata; it
enforces neither permission, rollback nor retry safety.
Body encoding and content type
With body_encoding: "utf8", the decoded UTF-8 bytes of body are delivered
unchanged. With "base64", strict canonical base64 carries arbitrary bytes,
including NUL. Base64 is an encoding, not encryption.
APIServer does not parse, normalize, or reserialize the node request. For example,
the envelope above delivers the bytes {"include_accounting":true} to
AuthenticationClient's status handler. A different registered operation can
define a different body format and content type. Use that node's reference for
the accepted schema; a valid envelope can still contain an invalid node request.
Response envelope
A successfully dispatched operation, including an operation that returns a node-level error, uses this envelope:
{
"id": "request-42",
"error": null,
"result": {
"code": 0,
"content_type": "application/json",
"body_encoding": "utf8",
"body": "{\"can_authenticate\":true}"
}
}
The body above is illustrative, not a complete status snapshot.
result.code preserves the handler's result: 0 is success and 1 is an error.
The registered operation declares the result content type and encoding, which
need not match the request's encoding. Decode result.body using the returned
body_encoding, then interpret it according to the target node's schema. An
empty handler result becomes an empty body with its declared type and encoding.
Operation references remain authoritative:
- AuthenticationClient status describes the client's local synchronization and admission view.
- AuthenticationServer management API describes account reads, pagination, creation, conditional edits, and password changes. Its unsigned 64-bit values use decimal JSON strings.
Gateway errors
A gateway failure has result: null:
{
"id": "request-42",
"error": {"code": "ambiguous_target", "message": "Specify the configuration name."},
"result": null
}
id is null when no trustworthy bounded correlation ID was recovered.
Messages are fixed and bounded; clients should branch on the error code.
| Code | Meaning |
|---|---|
invalid_request | Invalid framing or request-envelope schema. |
invalid_encoding | Unsupported or invalid body encoding. |
unauthorized | Invalid API credentials. |
forbidden | The operation is absent from the caller's exact allowlist. |
target_not_found | The selected public node could not be resolved. |
ambiguous_target | The unqualified node name matches multiple declared nodes. |
unsupported_content_type | The request content type does not match the operation. |
busy | Gateway or dispatch capacity could not admit the work. |
timeout | An API deadline expired. |
shutting_down | API admission has closed. |
response_too_large | A valid result cannot fit the encoded response limit or representable size. |
invalid_response | The handler returned invalid UTF-8 or NUL for a declared UTF-8 result. |
Shared clients also recognize generic unsupported_api and unsupported_operation
codes. Current APIServer does not emit them: startup resolves each permitted
descriptor, and requests absent from the allowlist receive forbidden.
Gateway busy and shutting_down are definite pre-dispatch refusals: the handler was not invoked.
Python create/update/password clients report these refusals without automatic retry. A node result containing
the same code is a separate operation outcome; apply that operation's commit contract.
A gateway error and a node error are different layers. For example, an
AuthenticationServer conflict is inside result.body with result.code: 1,
while a gateway timeout is in error and has no node result. Delivery failure
after dispatch can hide an already committed mutation; it does not turn that
mutation into a rollback. invalid_response is a post-dispatch failure, so a
mutation may already have committed. Invalid handler bytes are neither echoed
nor logged. Base64 operations may return arbitrary bytes.
Ordering, connection closure, and limits
Each connection executes one operation at a time. Complete pipelined requests wait in FIFO order, and replies retain that order. Separate connections can execute concurrently; operation-specific locks and preconditions govern their shared state.
Malformed framing/schema and invalid authentication close the connection after
at most one bounded error. Gateway timeout and invalid_encoding also close
the connection. Valid authorized target, operation, and body errors
may continue in FIFO order. A resource refusal can close directly when even an
error cannot fit. After a response starts, transport failure or output expiry
closes the connection without injecting a second frame into a partial response.
If a later queued request expires behind an earlier pending response, the
connection closes rather than returning its error out of order.
Clients should keep pipeline depth within the configured queue and request
deadline; a slow head operation does not suspend later requests' deadlines.
When the outer JSON has a valid id but later schema validation fails, the error
echoes that ID. Invalid or unavailable IDs produce null.
The APIServer limits table defines settings and defaults. Envelope caps exclude the four-byte prefix; memory limits also account for retained allocations, fragments, queues, jobs, and temporary working storage. A node can impose a smaller body or work limit. JSON escaping and base64 expand encoded results, so the raw node-body length alone does not establish that its response envelope fits. Declared request lengths do not allocate unread bodies: frame capacity grows for received bytes, charging old/new capacity overlap. The hard input-byte limit includes copied incomplete input; pressure watermarks subtract those copied bytes so receiving can continue after raw backlog or older queued requests drain.
Predictive Pause also holds input while older complete/active requests leave insufficient budget for the next
frame. Before its header is known, the check uses max_request_envelope + 4; afterward it uses the declared
complete size, counting already buffered bytes once. Completion/output drain autonomously releases the hold.
An incomplete frame alone can keep receiving. Pause is advisory; late or coalesced input can still exceed a
hard cap and close, and the original frame deadline remains absolute.
The limits apply together: max_jobs=64 does not guarantee 64 concurrent jobs. With current defaults roughly
seven small jobs fit the shared 64 MiB domain before substantial session storage because each reserves an
8 MiB handler allowance plus other storage. Actual retained input/output can reduce that capacity.
Connections that have not yet supplied valid credentials share the separate
max_unauthenticated_memory ceiling (16 MiB with defaults). Verification releases
that extra charge while retaining ordinary memory accounting. This limits anonymous
retention without exempting any later envelope from authentication; session-count
and CPU limits remain shared.
| Deadline | Interval |
|---|---|
| Frame assembly | From first-fragment arrival, including queue residence; trickle input does not reset it. |
| Request | From complete-request admission through response publication, including FIFO and worker wait. |
| Output | From publication through transport handoff, including paused time. |
| Idle | While no frame, request, or output is in progress. |
All configured limits are positive finite integers; zero does not disable a protection. A disconnect or timeout abandons delivery, but a running handler retains its reservations until it finishes. The gateway cannot interrupt and undo arbitrary work that has already committed.
Writes and uncertain replies
The request id is correlation only. Reusing it creates no replay guarantee,
checkpoint, or automatic deduplication. The generic client makes one attempt
and does not retry writes automatically.
The Python transport bounds each socket recv request to 64 KiB while preserving the accepted frame limit
and one original monotonic deadline across resolution, connection, send and all response chunks. Short reads
do not renew the deadline. Early EOF, oversized prefixes, malformed replies, timeout or transport loss after
sending leave mutation delivery uncertain; pre-dispatch refusals remain definite.
Consult the target operation's contract before retrying. AuthenticationServer's
public mutation APIs guarantee that a returned node-operation failure did
not commit that invocation. A lost, malformed, or late reply, a disconnect, or
a post-dispatch gateway error, including timeout or invalid_response, leaves
its outcome unknown. A success
acknowledges server memory; ordinary persistence and client refresh happen
separately.
After a conflict or uncertain result, reread and explicitly reconsider the requested change. Do not silently fetch a fresh edit token and replay a write. Account creation recovery keeps the original ID; public existence alone does not prove which caller created it. Public reads omit credentials, so they also cannot prove which password is stored after a lost password-change reply. See the account administration examples and each operation's reference for the appropriate recovery steps.