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:
{ "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 signedactivation_responsecontainer carrying the full lease window and entitlements:
{
"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:
200with the same shape as activate, exceptfp_idis 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 }, wherestatusis"active"when valid, otherwise the failing reason (expired/not_yet_valid) or the license status (revoked/suspended/ …), andmachine_statusis 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 signedcapabilitytoken whosedata.resultis the operation’s output (for a fused op,data.resultis 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 } }(cursoris0on 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 } }(cursoris 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? }—opsis a non-empty array of{ op, asset };ttlSecondsdefaults to 30 days and is clamped to a 90-day ceiling. - Success:
200 { "grant": { "armoured": "…", "container_base64": "…" } }— a signedoffline_grantcontainer. - Errors:
400 not_supported;400 bad_request(missing nonce, emptyops, 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? }—evidenceis a number in[0, 1](clamped server-side). - Success:
200 { action, evidence, verdict: { armoured, container_base64 } }, whereactionis"observe" | "flag" | "degrade" | "block"(ablockrequires high evidence and a prior strike), andevidenceis 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:
200with 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 }—pubRawHexmust be a 32-byte key in hex (64 hex characters). - Success:
200 { machine_id, enrolled: true }. - Errors:
404 not_found;400 bad_request(badpubRawHex).
POST/v1/telemetry
Report-only, lightweight client telemetry into the append-only event log.
- Auth: none.
- Request body:
{ machine_id, kind, data? }—datavalues 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(missingmachine_idorkind);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.
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:
201with the product:{ product_id, name, signing_kid, state: "active", ends_at: null, released_at: null }. - Errors:
400 bad_request(missingproduct_id).
POST/v1/admin/licenses
Issue a new license.
- Request body:
{ product, policy?, entitlements?, seats?, nbf?, exp?, lid? }—policyis a partial over the default policy;seatsdefaults to the policy’smax_seats;expdefaults tonull(perpetual). - Success:
201with 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;resumeignores 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_ida positive integer,versiona label,statusone ofactive/deprecated/retired(defaultactive),secretoptional hex of at least 16 bytes. - Success:
201with 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 thanfalseenables 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:
200with 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(badwindDownDays);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 inwind_down, or the window hasn’t elapsed andforceisn’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? }—opsis[{ op, asset }](like the offline grant);unsealKeysis the optional[{ section, k }]for a product with sealed sections. Both default to[]. - Success:
200 { "grant": { "armoured": "…", "container_base64": "…" } }— a signedrelease_grantcontainer (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-arrayops/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.
| Code | Status | Meaning |
|---|---|---|
bad_request | 400 | Missing or invalid field (nonce, evidence, ops entry, …). |
bad_json | 400 | Request body is not valid JSON. |
payload_too_large | 413 | Body exceeded 128 KiB (connection closed). |
not_found | 404 | No such license / machine / session (or license missing). |
unknown_product · unknown_op · unknown_build · unknown_session | 404 | No such named entity. |
product_mismatch | 403 | The license is not for the requested product. |
revoked · suspended · inactive · expired · not_yet_valid | 403 | License status or validity refusal. |
machine_inactive | 403 (activate / invoke) · 409 (heartbeat) | The machine is not active. |
scope_not_entitled | 403 | The license does not grant the requested scope. |
attestation_required · attestation_failed | 403 | Hardware attestation missing or invalid. |
tamper_blocked | 403 | Machine under a confirmed-tamper block; the server withholds value. |
seat_limit | 409 | All seats in use. |
env_not_nodelockable | 409 | A VDI / container environment can’t be node-locked. |
activation_rate_limited | 429 | Activation ceiling reached (details.retry_after_seconds). |
rate_limited | 429 | Per-license protected-compute rate cap exceeded. |
replay | 409 | Nonce already used. |
replay_cache_full | 503 | Replay cache full; retry later. |
desync · session_closed · session_exhausted · session_unbound | 409 · 409 · 409 · 403 | Co-processor session chain errors. |
op_not_fused | 400 | Only fused ops can be granted offline or in a release grant. |
op_failed | 500 | The protected operation itself failed. |
fraud_denied | 403 | Activation blocked by fraud checks. |
bad_fingerprint | 400 | The fingerprint object failed validation. |
bad_state | 409 | Lifecycle transition not allowed in the current state. |
not_released | 409 | Release grant requested before the product is released. |
not_supported | 400 | The feature (protected operations, version gating, co-processors) is not configured for this product. |
update_required | 426 | Build below the floor, unknown or retired. details = { reason, target, min_build }. An update prompt, not a lockout. |
internal | 500 | Unexpected server error (details are logged server-side, not leaked). |
unauthorized | 401 | /v1/admin/* without the correct bearer token. |
not_found (route) | 404 | { error: "not_found" } for any unmatched path or method. |