Docs · Server API

The license server, endpoint by endpoint.

JSON over HTTPS at https://api.bindseal.com. Hot-path routes serve your players’ activated clients; admin routes manage products, licenses, builds and offboarding.

Conventions

Base URL. https://api.bindseal.com. Every request and response body is JSON (content-type: application/json), capped at 128 KiB — an oversized body is refused with 413 payload_too_large.

Signed containers. Several responses carry a signed container in two encodings:

signed container
{ "armoured": "<ascii-armoured container>", "container_base64": "<base64 of the raw bytes>" }

The client verifies the container against its embedded trust store before trusting any field, so the value is authenticated end to end on top of TLS.

Error envelope. Errors are { "error": <code>, "message": <text> } with the error’s HTTP status, plus a details object when present (e.g. update_required). An unexpected error is 500 { "error": "internal" }; an unmatched route is 404 { "error": "not_found" }. See Error responses.

nonce. Hot-path write calls require a client nonce (string). It is echoed in the signed response and is single-use — a replay is refused with 409 replay.

build_id / build_proof. Optional on activate, heartbeat, invoke, offline-grant and session calls. When your product has version gating configured, a build below the floor, unknown or retired is refused with 426 update_required. These are update prompts, not lockouts.

You normally don’t call the hot-path routes by hand — the SDK does, and verifies every signed response for you.

Health

GET/healthz

  • Auth: none.
  • Response: 200 { "ok": true }.

Hot-path endpoints

Traffic from activated clients. None of these take an API token: the license key, machine seat and entitlements are the gate.

POST/v1/activate

Activate a machine against a license.

  • Auth: none (the license key is the credential).
  • Request body: { lid, product, fingerprint, nonce, build_id?, build_proof? } — lid (license key), product (product id; must match the license), fingerprint (fingerprint-v1 object), nonce (string).
  • Success: 200, with a signed activation_response container carrying the full lease window and entitlements:
200 OK
{
  "machine_id": "mch_…",
  "fp_id": "…",
  "lease_expires": 0,
  "grace_until": 0,
  "license": { "armoured": "…", "container_base64": "…" }
}
  • Errors: 404 not_found (no such license) / 404 unknown_product; 403 product_mismatch, 403 revoked / suspended / inactive / expired / not_yet_valid; 400 bad_request (missing nonce) / 400 bad_fingerprint; 403 fraud_denied; 409 env_not_nodelockable; 409 seat_limit; 429 activation_rate_limited (details.retry_after_seconds); 409 replay; 426 update_required.

POST/v1/heartbeat

Refresh the lease for an active machine; propagates revoke / suspend / expiry.

  • Auth: none.
  • Request body: { machine_id, nonce, build_id?, build_proof? }.
  • Success: 200 with the same shape as activate, except fp_id is absent: { machine_id, lease_expires, grace_until, license: { armoured, container_base64 } }.
  • Errors: 404 not_found; 409 machine_inactive; 403 revoked / suspended / expired; 400 bad_request; 409 replay; 426 update_required (the lease is not refreshed, but the machine’s status is untouched — the client keeps running on its cached lease).

POST/v1/validate

Read-only status check for a machine (no lease refresh).

  • Auth: none.
  • Request body: { machine_id }.
  • Success: 200 { status, machine_status, lease_expires, grace_until }, where status is "active" when valid, otherwise the failing reason (expired / not_yet_valid) or the license status (revoked / suspended / …), and machine_status is the machine’s own status.
  • Errors: 404 not_found (no such machine, or license missing).

POST/v1/invoke

Invoke a server-side protected operation (hollow or fused). The valuable logic runs on the server; the client receives only a signed capability token.

  • Auth: none (gated by machine seat, license and entitlement).
  • Request body: { machine_id, op, input, nonce, attestation?, build_id?, build_proof? }.
  • Success: 200 { "capability": { "armoured": "…", "container_base64": "…" } } — a signed capability token whose data.result is the operation’s output (for a fused op, data.result is the operand { k, asset, tweak? }).
  • Errors: 400 not_supported (no protected operations configured); 400 bad_request; 404 not_found; 403 machine_inactive; 403 revoked / suspended / expired / not_yet_valid; 404 unknown_op; 403 scope_not_entitled; 403 attestation_required / attestation_failed; 403 tamper_blocked; 429 rate_limited; 409 replay; 426 update_required; 500 op_failed.

POST/v1/session/open

Open a live co-processor session. The server computes and stores the authoritative initial state and returns a signed receipt carrying only a hash of it.

  • Auth: none (gated like invoke).
  • Request body: { machine_id, op, input?, nonce, attestation?, build_id?, build_proof? }.
  • Success: 200 { session_id, cursor, state_hash, capability: { armoured, container_base64 } } (cursor is 0 on open).
  • Errors: 400 not_supported (no co-processors configured); 400 bad_request; 404 not_found; 403 machine_inactive; 403 revoked / suspended / expired; 404 unknown_op; 403 scope_not_entitled; 403 attestation_required / attestation_failed; 403 tamper_blocked; 429 rate_limited; 409 replay; 426 update_required; 500 op_failed.

POST/v1/session/step

Advance a co-processor session by one step. The client must echo the last-issued cursor and prevStateHash; a mismatch is a desync.

  • Auth: none.
  • Request body: { machine_id, session_id, input?, nonce, cursor, prevStateHash, attestation?, build_id?, build_proof? }.
  • Success: 200 { session_id, cursor, state_hash, capability: { armoured, container_base64 } } (cursor is incremented).
  • Errors: 400 not_supported / 400 bad_request; 404 not_found; 403 machine_inactive; 404 unknown_session; 409 session_closed; 403 session_unbound; 403 scope_not_entitled; 409 desync (details.expected_cursor); 409 session_exhausted; 429 rate_limited; 409 replay; 426 update_required; 500 op_failed.

POST/v1/session/close

Close a co-processor session (terminal, idempotent).

  • Auth: none.
  • Request body: { machine_id, session_id }.
  • Success: 200 { session_id, status: "closed", steps }.
  • Errors: 404 unknown_session; 403 session_unbound.

POST/v1/offline-grant

Issue a signed, machine-bound, expiring bundle of fused operands for offline play.

  • Auth: none (gated like invoke).
  • Request body: { machine_id, ops, nonce, ttlSeconds?, build_id?, build_proof? } — ops is a non-empty array of { op, asset }; ttlSeconds defaults to 30 days and is clamped to a 90-day ceiling.
  • Success: 200 { "grant": { "armoured": "…", "container_base64": "…" } } — a signed offline_grant container.
  • Errors: 400 not_supported; 400 bad_request (missing nonce, empty ops, or a malformed { op, asset } entry); 404 not_found; 403 machine_inactive; 403 revoked / suspended / expired; 404 unknown_op; 403 scope_not_entitled; 400 op_not_fused (the op does not produce a fused operand); 403 tamper_blocked; 429 rate_limited; 409 replay; 426 update_required; 500 op_failed.

POST/v1/adjudicate

Ingest a client’s self-reported tamper evidence and return a signed server verdict.

  • Auth: none.
  • Request body: { machine_id, nonce, evidence, report? } — evidence is a number in [0, 1] (clamped server-side).
  • Success: 200 { action, evidence, verdict: { armoured, container_base64 } }, where action is "observe" | "flag" | "degrade" | "block" (a block requires high evidence and a prior strike), and evidence is the clamped value.
  • Errors: 400 bad_request (missing nonce / non-numeric evidence); 404 not_found; 403 revoked; 409 replay.

POST/v1/deactivate

Self-service deactivation — frees the seat (for example when a player moves to a new PC).

  • Auth: none.
  • Request body: { machine_id }.
  • Success: 200 { machine_id, status } — "deactivated", or the machine’s existing status if it was already inactive (idempotent).
  • Errors: 404 not_found.

POST/v1/attest/challenge

Issue a single-use, time-boxed hardware-attestation challenge for a machine.

  • Auth: none.
  • Request body: { machine_id }.
  • Success: 200 with the challenge object (stored on the machine for the next attested call).
  • Errors: 404 not_found.

POST/v1/attest/enroll

Enroll a machine’s hardware-bound public key (TPM / Secure Enclave).

  • Auth: none.
  • Request body: { machine_id, pubRawHex } — pubRawHex must be a 32-byte key in hex (64 hex characters).
  • Success: 200 { machine_id, enrolled: true }.
  • Errors: 404 not_found; 400 bad_request (bad pubRawHex).

POST/v1/telemetry

Report-only, lightweight client telemetry into the append-only event log.

  • Auth: none.
  • Request body: { machine_id, kind, data? } — data values are bounded (numbers, booleans, or strings of at most 256 characters; at most 32 keys are stored).
  • Success: 200 { ok: true }.
  • Errors: 400 bad_request (missing machine_id or kind); 404 not_found.

Admin endpoints

Every /v1/admin/* route requires your admin token as a bearer token. A missing or wrong token is refused with 401 { "error": "unauthorized" }. Keep this token on your own servers — never ship it in your game.

shell
curl -X POST https://api.bindseal.com/v1/admin/licenses \
  -H "Authorization: Bearer $BINDSEAL_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"product":"my-game","seats":1}'

POST/v1/admin/products

Provision a product.

  • Request body: { product_id, name? }.
  • Success: 201 with the product: { product_id, name, signing_kid, state: "active", ends_at: null, released_at: null }.
  • Errors: 400 bad_request (missing product_id).

POST/v1/admin/licenses

Issue a new license.

  • Request body: { product, policy?, entitlements?, seats?, nbf?, exp?, lid? } — policy is a partial over the default policy; seats defaults to the policy’s max_seats; exp defaults to null (perpetual).
  • Success: 201 with the license: { lid, product_id, status: "active", binding_type, policy, entitlements, seats, nbf, exp, created_at }.
  • Errors: 404 unknown_product; 400 bad_request (seats < 1).

POST/v1/admin/licenses/:lid/file

Mint a signed, deliverable license_file (binding type from the license; fp_id is null until activation).

  • Request body: none.
  • Success: 200 { payload, license: { armoured, container_base64 } }.
  • Errors: 404 not_found.

POST/v1/admin/licenses/:lid/revoke · /suspend · /resume

Change a license’s status.

  • Request body: { reason? } (revoke / suspend; resume ignores the body).
  • Success: 200 { lid, status } — "revoked", "suspended" or "active" (resume).
  • Errors: 404 not_found; 409 bad_state (revoking an already-revoked license; resuming a license that isn’t suspended).

POST/v1/admin/products/:id/builds

Register a release build (version gating).

  • Request body: { build_id, version, salt?, status?, secret? } — build_id a positive integer, version a label, status one of active / deprecated / retired (default active), secret optional hex of at least 16 bytes.
  • Success: 201 with the build (the secret is never returned): { product_id, build_id, version, salt, status, released_at, has_secret }. The first build registered becomes the initial support floor.
  • Errors: 400 not_supported (version gating not configured); 404 unknown_product; 400 bad_request (invalid fields).

POST/v1/admin/products/:id/min-build

Raise the minimum supported build — the forced-update floor. It only moves forward.

  • Request body: { build_id }.
  • Success: 200 { product_id, min_supported_build }.
  • Errors: 400 not_supported; 404 unknown_build / unknown_product; 409 floor_regression (the floor can’t move backward); 400 bad_request.

POST/v1/admin/products/:id/require-build-proof

Turn per-build proof-of-possession on or off (opt-in; off by default).

  • Request body: { require_proof? } — anything other than false enables it.
  • Success: 200 { product_id, require_proof }.
  • Errors: 400 not_supported; 409 no_secrets (can’t enable without a build that carries a secret); 400 bad_request.

POST/v1/admin/products/:id/builds/:build_id/retire

Retire a build. Clients on it are told to update — never locked out.

  • Request body: none.
  • Success: 200 with the updated build (status: "retired").
  • Errors: 400 not_supported; 404 unknown_build; 400 bad_request.

POST/v1/admin/machines/:id/clear-tamper

Clear a tamper hold — for example after investigating a false positive.

  • Request body: none.
  • Success: 200 { machine_id, tamper_action: null }.
  • Errors: 404 not_found.

POST/v1/admin/products/:id/terminate

Begin contract-end wind-down: active → wind_down with ends_at = now + windDownDays × 86400. Idempotent; the hot path keeps working while winding down.

  • Request body: { windDownDays? } — a non-negative integer (default 30).
  • Success: 200 { product_id, state: "wind_down", ends_at }.
  • Errors: 404 unknown_product; 400 bad_request (bad windDownDays); 409 bad_state (already released).

POST/v1/admin/products/:id/release

Flip wind_down → released (enforcement off). Allowed only from wind_down, and only once the window has elapsed unless force is set. Idempotent once released.

  • Request body: { force? }.
  • Success: 200 { product_id, state: "released", released_at }.
  • Errors: 404 unknown_product; 409 bad_state (not in wind_down, or the window hasn’t elapsed and force isn’t set).

POST/v1/admin/products/:id/release-grant

Mint the signed, perpetual, product-wide release grant you ship in an offboarding build (see loadReleaseGrant). Refused unless the product is released.

  • Request body: { ops?, unsealKeys? } — ops is [{ op, asset }] (like the offline grant); unsealKeys is the optional [{ section, k }] for a product with sealed sections. Both default to [].
  • Success: 200 { "grant": { "armoured": "…", "container_base64": "…" } } — a signed release_grant container (enforcement: "off", no expiry, no machine binding).
  • Errors: 400 not_supported (no protected operations configured); 404 unknown_product; 409 not_released (release the product first); 400 bad_request (non-array ops / unsealKeys, or a malformed entry); 404 unknown_op; 500 op_failed; 400 op_not_fused.

GET/v1/admin/products/:id/export

Export a product’s license, activation and event data. Your data is yours.

  • Request body: none.
  • Success: 200 { product: { product_id, name, state, ends_at, released_at }, licenses: [...], activations: [...], events: [...] }.
  • Errors: 404 unknown_product.

Error responses

Every error is { error: <code>, message: <text> } with the status below; details is added when present.

CodeStatusMeaning
bad_request400Missing or invalid field (nonce, evidence, ops entry, …).
bad_json400Request body is not valid JSON.
payload_too_large413Body exceeded 128 KiB (connection closed).
not_found404No such license / machine / session (or license missing).
unknown_product · unknown_op · unknown_build · unknown_session404No such named entity.
product_mismatch403The license is not for the requested product.
revoked · suspended · inactive · expired · not_yet_valid403License status or validity refusal.
machine_inactive403 (activate / invoke) · 409 (heartbeat)The machine is not active.
scope_not_entitled403The license does not grant the requested scope.
attestation_required · attestation_failed403Hardware attestation missing or invalid.
tamper_blocked403Machine under a confirmed-tamper block; the server withholds value.
seat_limit409All seats in use.
env_not_nodelockable409A VDI / container environment can’t be node-locked.
activation_rate_limited429Activation ceiling reached (details.retry_after_seconds).
rate_limited429Per-license protected-compute rate cap exceeded.
replay409Nonce already used.
replay_cache_full503Replay cache full; retry later.
desync · session_closed · session_exhausted · session_unbound409 · 409 · 409 · 403Co-processor session chain errors.
op_not_fused400Only fused ops can be granted offline or in a release grant.
op_failed500The protected operation itself failed.
fraud_denied403Activation blocked by fraud checks.
bad_fingerprint400The fingerprint object failed validation.
bad_state409Lifecycle transition not allowed in the current state.
not_released409Release grant requested before the product is released.
not_supported400The feature (protected operations, version gating, co-processors) is not configured for this product.
update_required426Build below the floor, unknown or retired. details = { reason, target, min_build }. An update prompt, not a lockout.
internal500Unexpected server error (details are logged server-side, not leaked).
unauthorized401/v1/admin/* without the correct bearer token.
not_found (route)404{ error: "not_found" } for any unmatched path or method.