Developer API

This page fully specifies KeyLockr SSO, end-to-end encrypted AppData, optional Access Point authorization, and backend verification. Every required wire format, cryptographic rule, error, and example is included.

Overview

One external identity field

All external SSO requests send the effective app_tag. Without a custom tag, the value is the decimal Service ID, but it still travels in app_tag; there is no external svc_id or binding_type.

Device bindings use safe_id + app_tag + sign_pk; an AppData namespace uses safe_id + app_tag.

The Safe always displays the title registered in MyDeveloper. Handshake name is only the physical device name and cannot spoof the service name.

sso

The base identity for every registered App. Create your own login session from safe_id after Safe approval.

app_data

Optional E2E encrypted namespace. Clients for one Safe/tag share ciphertext while retaining separate keypairs.

ap

Optional, approval-gated Access Point capability with full access or an explicit scope subset.

Delegated SSO quick start

  1. The App calls its own backend /kl/login/start.
  2. The App opens the backend-provided deep link and waits for its registered Universal Link/App Link callback.
  3. The App sends its private finish_secret and opaque assertion to its own backend /kl/login/finish.
Network boundary: the third-party App does not call KeyLockr HTTP/KPS/WebSocket, and its backend does not call KeyLockr during finish. Only KeyLockr Mobile calls the KeyLockr backend as the Safe.

Register a service

my.keylockr.app/developer

FieldOwnerRule
titleDeveloperRequired and public; the authoritative App name shown by the Safe and third-party login pages.
app_tagDeveloperOptional custom identity. Blank uses the decimal Service ID and may be replaced once later. A custom value must be globally unique, non-numeric, and non-reserved; after it is set, it is locked and every client/backend configuration must switch to the new tag.
server_ipsDeveloperOne actual fixed backend egress IPv4 or IPv6 address per line for app_verify and sso_user_pubkey. Dual-stack services must add both addresses. Blank disables both; it does not allow all callers.
data_approvedDeveloperAllows new bindings to request app_data=true.
ap_approvedAdminAllows new bindings to request ap=true.
ap_allowed_scopesAdminMaximum AP scopes; NULL permits a full-AP request.
plugin_approvedAdminApproves direct Mobile Safe access to a plugin backend.
plugin_api_urlAdminApproved plugin backend URL.
sso_key_ownerAdminHandshake key owner: client (default) or backend. Only backend mode overrides the request enc_pk, and it does not support AP.
encrypt_pk_base64AdminOptional third-party backend X25519 service key, always published by /svc_pk and required for backend ownership. Client ownership may retain this key without changing the App handshake.
auth_request_sign_pk_current_base64DeveloperCurrent Ed25519 public key for delegated requests; standard Base64 decoding to exactly 32 bytes.
auth_request_sign_pk_previous_base64DeveloperOptional previous Ed25519 public key, accepted only until its explicit deadline; v1 rotation is manual.
auth_request_sign_previous_accept_untilDeveloperUTC RFC3339 inclusive deadline for the previous key; a new or changed overlap must retain at least 660 seconds.
mobile_return_urlsDeveloperOne exact canonical callback URL per line; no query, fragment, wildcard, or dangerous scheme.
delegated_sso_enabledDeveloperPer-service switch, off by default; it can be enabled only when every required field is valid.

An app_tag is a public identifier, not a client secret. App Attest is not part of this flow, and there is no app_attest_challenge API.

Keys and environments

PurposeProductionDevelopment
HTTP APIhttps://api.keylockr.app/v3https://apidev.keylockr.app/v3
WebSocketwss://api.keylockr.app/v3/wswss://apidev.keylockr.app/v3/ws
Server box pkGET /key_encGET /key_enc
Server signing pkGET /key_signGET /key_sign
Deployed buildGET /build_verGET /build_ver

Key endpoints return a standard-Base64 32-byte public key. With client ownership, every physical App/Browser generates and securely retains its own Ed25519 signing and X25519 box keypairs; even when the third-party backend has a registered service key, the client never receives or uses the backend enc_sk. A backend-owned logical deployment shares one persistent server keypair and registers its X25519 enc_pk in MyDeveloper. Transmit only public keys; private keys never leave their owner.

sign_pk, sign_sk = nacl.sign.keyPair() // 32-byte pk, 64-byte sk
enc_pk, enc_sk   = nacl.box.keyPair()  // 32-byte pk, 32-byte sk
A lost private key requires a new QR authorization. An old app_id is not a bearer token and cannot be reused with a new keypair.

Delegated SSO v1

/start must run immediately before the App shows a QR or opens the deep link. From one start_wall_time snapshot, the backend derives a 600-second challenge deadline and a 720-second retention deadline, using independent CSPRNG outputs for the public challenge and private finish_secret.

POST your-backend.example/kl/login/start

// Cache-Control: no-store
{
  deeplink: string,        // keylockr://sso-request?request=...
  finish_secret: string,   // private, 32-byte base64url
  request_digest: string,  // exact SHA-256(auth_request)
  expires_at: number       // Unix seconds
}

The signed request uses a fixed 11-field MessagePack claim, the current Ed25519 key, and its kid. The challenge is a public transaction ID, not an authorization credential; only the original App holding the finish_secret can finish. The request also signs the app_tag, return URL, capabilities, optional client public keys, device name, and AP scopes.

Delegated v1 accepts client ownership only and requires both client_sign_pk and client_enc_pk. Backend-owned services have no Ed25519 binding identity in this wire version and must use the legacy advanced handshake below; an X25519 encrypt_pk_base64 is not a signing key.

Quick start always uses a verified-domain Universal Link/App Link. Another App may claim a custom scheme and observe the opaque assertion. Opening the box proves only that it targets this backend; you must still verify the KL seal, every claim, request digest, and finish_secret.
POST your-backend.example/kl/login/finish
{
  finish_secret: string,
  backend_assertion: string
}

// After offline KL seal + box + claim verification and atomic Redis completion:
{
  session_result: object,
  safe_id: string,
  user_nickname: string
}

Finish does not call KeyLockr or /app_verify. Pending/completed state lives only in Redis. An exact secret/assertion retry returns the same session; mismatches and expired replays return the same generic authentication failure. Before activating the session, the login client must show the verified nickname and full Safe ID for explicit confirmation. Safe ID, not nickname, is the stable identity.

KeyLockr Mobile internally calls app_delegated_info and app_delegated_approve. These Safe KPS actions are not third-party App/backend APIs.

LimitFixed value
auth_request4 KiB decoded
return_url2 KiB UTF-8; no query/fragment
backend assertion / client result3 KiB / 12 KiB
success wrapper / encoded result / callback URL16 KiB / 21,846 chars / 24 KiB
request / assertion lifetime600 seconds / 120 seconds

Delegated v1 does not permit data_deferred or a third-party App fallback to KeyLockr. Any inline overflow ends with app_auth_result_too_large. Rejection is exactly keylockr_result=cancel and must not call finish.

Legacy advanced: capability handshake

This section preserves the existing client-owned/backend-owned handshake, temporary WebSocket, native callback, and /app_verify compatibility flow. New mobile SSO must not silently fall back here after delegated failure.
POST https://api.keylockr.app/v3/handshake
Content-Type: application/octet-stream
Accept: application/octet-stream

// MessagePack, not JSON and not KPS-encrypted
{
  sign_pk: Uint8Array,          // exactly 32 bytes
  enc_pk?: Uint8Array,          // 32 bytes for client ownership; ignored for backend
  app_tag: "your_effective_tag",
  name?: "Work MacBook",        // required for AppData or AP
  app_data?: true,
  ap?: true,
  requested_scopes?: ["2fa"]    // AP only; non-empty when present
}
// -> { _res: "ok", tmp_id: string }

The sign_pk is always required and exactly 32 bytes. With sso_key_owner=client, enc_pk is required and exactly 32 bytes; KL does not override the request key even when encrypt_pk_base64 is registered. With sso_key_owner=backend, KL ignores any supplied or omitted enc_pk and uses only a valid registered 32-byte X25519 service key; a missing or invalid key never falls back.

RequestApprovalnameExpected capabilities
SSOvalid serviceoptional["sso"]
SSO + AppDatadata_approvedrequired["sso","app_data"]
SSO + APap_approvedrequired["sso","ap"]
SSO + AppData + APbothrequired["sso","app_data","ap"]

After trimming, name must contain 1–255 Unicode characters. With ap=true, omitting requested_scopes requests full AP; a restricted service must send a non-empty approved subset. Never send scopes without ap; only sso_key_owner=client may request AP, regardless of whether a separate service key exists.

The tmp_id and its temporary key/capability state live for 3600 seconds. Check _res before reading every HTTP response, even when its HTTP status is 200.

WebSocket, QR, and authorization result

1. Sign the WebSocket query
message = utf8("tmp.{tmp_id}.{unix_seconds}")
signed  = nacl.sign(message, sign_sk) // 64-byte signature || message
sig     = base64url_without_padding(signed)

wss://api.keylockr.app/v3/ws?sig={urlencoded_sig}

Use the NaCl signed-message form, not a detached signature. Timestamps are accepted from 10 minutes in the past through 3 minutes in the future.

2. Show one QR only after the socket connects
sso://login?tmp_id={tmp_id}
keylockr://sso?tmp_id={tmp_id}

Both URI forms use the same tmp_id. Name, AppData, AP, and scopes live in server handshake state and never in the QR. Backend-owned must open connected with its persistent enc_sk before showing the QR; under explicit backend ownership, an untrusted caller cannot decrypt or continue KPS because its handshake enc_pk is replaced by the registered key.

3. Native apps may direct-open and return
keylockr://sso?tmp_id={tmp_id}&return_url={percent_encoded_absolute_uri}

The return_url must be an absolute URI with a valid scheme, is direct-link only, and must never be included in a QR. After approval, the Safe may append keylockr_result={base64url_without_padding(completion_kps)}; it is the complete server-signed app_auth_result KPS encrypted only for this client and is the same completion result delivered by the temporary WebSocket.

Accept exactly one keylockr_result. Base64url-decode it, verify the KPS seal, box, and timestamp, then exactly match tmp_id, identity, and capabilities; the URL itself is not a bearer token. The callback and WebSocket should share one waiter that settles on the first valid result and deduplicates later replays. If no result is present, fall back to the WebSocket.

The Safe rejects about, blob, content, data, facetime, facetime-audio, file, intent, javascript, keylockr, mailto, market, sms, sso, tel, telprompt, and every scheme matching itms* (for example itms-services).

4. Validate the completion event
// Decrypted KPS frame:
header.from = "app_auth_result"
body = {
  _res: "ok",
  status: "done",
  tmp_id: string,
  app_id: string,
  safe_id: string,
  capabilities: string[],
  user_nickname: string,
  user_enc_pk: Uint8Array,
  ap_access?: { full_access: boolean, scopes: string[] },
  account_key_for_ap?: Uint8Array,
  data_filekey?: Uint8Array,
  data_plain?: Uint8Array,
  data_encrypted?: Uint8Array,
  data_deferred?: true,
  ver?: string
}

// Terminal completion when the frame cannot be compacted:
body = {
  _res: "ok",
  status: "error",
  tmp_id: string,
  code: "app_auth_result_too_large"
}
Before saving any ID or key, verify KPS and require a tmp_id exactly matching this handshake before interpreting status. On status=error, require a non-empty code and terminate. Only status=done may proceed to validate app_id/safe_id, capabilities, AppData ver, and AP fields.

Completion may arrive through the temporary WebSocket or a native callback; there is no HTTP polling. After a brief disconnect, reconnect with the same keypair while tmp_id remains valid to replay the completed result.

The completion KPS shared by WebSocket and callback is limited to 12 KiB. data_deferred=true is set only when the Safe supplied a client filekey and AppData content was actually omitted; retain data_filekey and ver, then call app_get_data. Without a client filekey, an older Safe result that would exceed the limit omits only the currently unusable data_encrypted, retains data_plain and ver, and does not set data_deferred; call app_req_filekey for the filekey, then obtain the latest encrypted content from app_filekey_result or app_get_data. A result still over the limit after trimming terminates with app_auth_result_too_large.

The AP Account Key format is nonce(24) || nacl.box(AK, nonce, client_enc_pk, safe_enc_sk). Open it with user_enc_pk and the client enc_sk, require 32 bytes, and never log or upload it.

// Existing AP only: recover a locally lost Account Key.
action: "app_req_account_key"
body: {}

{
  _res: "ok",
  account_key_for_ap: Uint8Array,
  safe_enc_pk: Uint8Array
}
// or { _res: "ok", status: "not_available" }

not_available means the binding never completed Account Key delivery; delete it and authorize again because there is no background repair. A non-AP binding receives app_not_ap.

KPS crypto protocol

Every WebSocket frame and POST /v3/ App action uses binary MessagePack KPS.

Client to server

{
  kps: {
    id: "tmp.{tmp_id}" | "app.{app_id}",
    app_ver: string,
    box: Uint8Array,
    n: Uint8Array,      // exactly 24 random bytes
    raw?: Uint8Array[]
  },
  seal: Uint8Array
}

inner = msgpack({
  header: { ts: unix_seconds, to: action },
  body: action_params
})
box  = nacl.box(inner, n, server_enc_pk, client_enc_sk)
seal = nacl.sign(
  sha256(canonical_msgpack_with_sorted_map_keys(kps)),
  client_sign_sk
)

Server to client

{
  kps: {
    box: Uint8Array,
    n: Uint8Array,
    raw?: Uint8Array[]
  },
  seal: Uint8Array
}

// After seal verification and box.open:
{
  header: { ts: number, from: string },
  body: {
    _res: "ok" | "err",
    code?: string,
    msg?: string
  }
}

The receive order is fixed: decode outer MessagePack, open seal with the server signing key, compare sha256(canonical msgpack(kps)), validate the 24-byte nonce, open the box with the server encryption key and client enc_sk, decode inner MessagePack, then check body._res. Abort on any failure.

When a body contains a numeric field__ index, restore field from kps.raw[index] with bounds checking. Raw is only for already encrypted blobs: the seal protects integrity, but the box does not hide it. You may instead place data_enc directly in the encrypted body.

The seal uses a NaCl signed-message, and canonical MessagePack must sort map keys. header.ts is Unix seconds, not milliseconds, accepted from 10 minutes past through 3 minutes future.

Complete AppData flow

AppData actions use the app.{app_id} KPS identity. KeyLockr stores only an encrypted filekey and application ciphertext and never holds the plaintext filekey.

1. Use the authorization result; request a filekey only when needed

A current Safe's initial app_auth_result directly supplies data_filekey, ver, and existing data_encrypted when it fits the completion limit; a new namespace may be empty. After validation, open the filekey and use that data immediately. If data_deferred=true, call app_get_data directly. Neither path needs a second phone unlock. Use app_req_filekey only when an older Safe omitted data_filekey, the local key was later lost, or a separate re-unlock is required.

For the compatibility path, connect the App WebSocket with nacl.sign("app.{app_id}.{ts}", sign_sk) and register an app_filekey_result waiter before sending the action, so a fast approval cannot be missed.

action: "app_req_filekey"
body: {}

// Immediate or polling KPS response:
{
  _res: "ok",
  status: "done" | "safe_auth_required" | "denied",
  data_filekey?: Uint8Array, // done only
  ver?: string              // done only
}

// App WebSocket completion:
header.from = "app_filekey_result"
body = {
  _res: "ok",
  status: "done" | "denied",
  data_filekey?: Uint8Array,
  data_enc?: Uint8Array,
  ver?: string
}

A safe_auth_required status means the Safe has been notified. Wait for the push while WebSocket is available; use spaced polling only after disconnect or timeout. Polling done does not include data_enc, so call app_get_data next.

2. Open data_filekey

// MessagePack envelope; new data uses full names.
{
  encFileKey: Uint8Array,   // 48 bytes
  nonceForKey: Uint8Array,  // 24 bytes
  nonceForData: Uint8Array, // 24 bytes; v1 read fallback only
  apEncPk: Uint8Array       // 32-byte sender pk
}

// Read-only legacy aliases:
{ k, nk, nd, p }

file_key = nacl.box.open(
  encFileKey,
  nonceForKey,
  apEncPk,
  client_enc_sk
)
// file_key must be exactly 32 bytes.

Readers normalize all four fields with fullName ?? legacyShortName and validate lengths. nonceForData is only for legacy v1 reads and must never be used for new writes.

3. Read and decrypt

action: "app_get_data"
body: {}

// KPS response:
{
  _res: "ok",
  file_id: string,
  ver: string,
  data_plain: Uint8Array,
  data_encrypted: Uint8Array
}

The data_plain field is Safe-created MessagePack metadata {name, app_tag} and is not third-party mutable. Store application data only in data_encrypted.

// Every new write is v2:
nonce = randomBytes(24)
data_encrypted =
  nonce || nacl.secretbox(msgpack(application_data), nonce, file_key)

// Read:
1. If length >= 40, try nonce-prefix v2 and verify its MAC.
2. Only if v2 fails, try the whole blob as v1 with nonceForData.
3. If both fail, abort. Never parse unauthenticated bytes.

4. Write with version CAS

action: "app_set_data"
body: {
  ver: string,          // exact latest value
  data_enc: Uint8Array  // complete v2 nonce-prefixed ciphertext
}

// KPS response:
{ _res: "ok", file_id: string, ver: string }

ver is mandatory. On file_ver_conflict, call app_get_data again, decrypt, merge in your application, and retry with the new version. KeyLockr does not retry, merge, create revisions, or permit app_set_data to update data_filekey.

Binding lifecycle

Update the local display name

action: "app_update"
body: {
  name?: string,
  name_enc?: Uint8Array
}

// -> { _res: "ok", name?: string, name_enc?: Uint8Array }

At least one of name or name_enc is required. An App may update only its own name, not AP scopes. name_enc uses the AP Account Key nonce(24) || secretbox format; an SSO/AppData client without the Account Key must not fabricate it with another key.

Permanently revoke the current binding

action: "app_del"
body: {}

// -> { _res: "ok", id_deleted: string }
app_del permanently revokes the current app_id. If it is the last binding referencing an AppData file, the server deletes that file in the same transaction. On success, immediately clear the local app_id, private keys, filekey, and session.

Backend verification and service keys

POST /v3/app_verify

Content-Type: application/json

{
  app_tag: "your_effective_tag",
  app_id: string,
  safe_id: string,
  sign_pk: string // standard base64, same 32-byte Ed25519 pk as handshake
}

// Normal mismatch:
{ _res: "ok", valid: false }

// Verified:
{ _res: "ok", valid: true, nickname: string }

// Allowlist configuration/rejection:
{ _res: "err", code: "server_verification_disabled" | "ip_not_allowed",
  request_ip: string }

This endpoint is for App/Browser claims with sso_key_owner=client. The caller must be in server_ips. Check _res first; an IP, configuration, or parameter error with _res=err is not valid=false. request_ip is the canonical IPv4/IPv6 used by KL; an invalid source is returned as an empty string rather than reflected. Add a non-empty value in MyDeveloper only after confirming it is your fixed egress. KL never returns the full allowlist. After verification, have your backend issue its own short-lived session; app_id is not a bearer token. The service may separately register encrypt_pk_base64 for data encrypted to the backend, but the App still uses only its own enc_pk/enc_sk; never give the backend enc_sk to the App.

POST /v3/sso_user_pubkey

Content-Type: application/json

{ app_tag: string, safe_id: string }

{ _res: "ok", found: false }
// or
{ _res: "ok", found: true, sign_pk: string } // standard base64 Ed25519 pk

Only an approved plugin backend with server_ips, plugin_approved, and plugin_api_url may call this endpoint. found=false intentionally does not reveal whether approval, binding, or a key is missing.

GET https://my.keylockr.app/svc_pk/{app_tag}

{ _res: "ok", pk_base64: string }

This endpoint only publishes MyDeveloper encrypt_pk_base64; it neither publishes nor infers sso_key_owner. A non-empty value serves service-to-service profile/data encryption independently of plugin approval and handshake ownership. Only when sso_key_owner=backend is separately selected does KL ignore the request enc_pk and require the server to retain the matching enc_sk. Client ownership may keep a non-empty service key while the App handshake still uses its own key.

GET https://my.keylockr.app/svc_info/{urlencoded_app_tag}

{ _res: "ok", title: string }
// Unknown or invalid identity:
{ _res: "err", code: "invalid svc" }

This public JSON endpoint lets third-party login pages retrieve the authoritative MyDeveloper display name. Put app_tag in one path segment using UTF-8 percent-encoding. The response contains only title; it does not return url, description, sso_key_owner, approval state, or private configuration. Treat the title as untrusted text, never inject it as HTML, and cache it for the lifetime of the current service configuration. On error, use your own product fallback name. The endpoint is IP-rate-limited; handle rate-limit responses with the standard try_later rules.

Errors, retries, and troubleshooting

Code/statusRequired handling
binding_key_invalidRequire sign_pk, and the client-owned request enc_pk, to be exactly 32 bytes.
sso_key_owner_invalidSet MyDeveloper sso_key_owner to client or backend.
sso_backend_key_requiredBackend ownership requires a registered service encryption key.
sso_backend_key_invalidCorrect the backend-owned service key to a standard-Base64 32-byte X25519 public key; do not fall back to the request key.
sso_backend_key_ap_conflictBackend ownership does not support AP; select client ownership or disable AP, then start a new handshake.
binding_name_invalidAppData/AP requires a 1–255 Unicode-character physical device name.
sso_service_invalidUse the effective app_tag, not a title or svc_id field.
sso_data_not_approvedEnable AppData in MyDeveloper, then start a new handshake.
ap_not_approved / ap_scope_not_approvedRequest only an approved AP capability and scope set.
binding_scope_invalidScopes require ap=true; when present they must be non-empty, non-blank, and unique.
tmpid_expiredCreate a new handshake, temporary WebSocket, and QR.
data_expiredCalibrate Unix-second time with GET /clock, then retry.
sign_invalidStop and ensure sign_sk matches the bound sign_pk.
hash_mismatchFix sorted-key canonical MessagePack.
app_404 / app_pkdata_invalidClear the local app_id and authorize again.
app_action_not_allowedThe binding lacks that capability; do not retry to bypass it.
app_not_apA non-AP binding cannot request an Account Key.
app_file_not_foundAn auth-only binding has no AppData; authorize again with app_data=true.
file_ver_conflictRead, decrypt, merge, and retry with the latest ver.
server_verification_disabledConfigure non-empty server_ips in MyDeveloper; request_ip is the canonical observed source, or empty when it cannot be parsed.
ip_not_allowedConfirm request_ip is your fixed egress, then add that IPv4 or IPv6 address to server_ips.
try_later N ...Wait for the specified duration and retry only after a user action.
try_later_auto N ...Wait for the specified duration, then retry automatically.
safe_auth_requiredWait for the filekey WebSocket push; poll only after disconnect or timeout.
deniedStop waiting and show that the Safe denied the request.

Every wait and retry requires a local timeout and cancellation path. Reply to a KPS ping event with KPS pong. Never log private keys, the Account Key, filekeys, or decrypted AppData.

API reference

Endpoint/actionEncoding/identityPurpose
GET /v3/key_encplain base64 / publicServer X25519 public key.
GET /v3/key_signplain base64 / publicServer Ed25519 public key.
GET /v3/clockJSON / publicUnix-second clock calibration.
GET /v3/build_verJSON / publicCurrent deployed build, not cached.
POST /v3/handshakeMessagePack / publicCreate an App-capability authorization tmp.
GET /v3/ws?sig=KPS / signed queryTemporary or App WebSocket.
POST /v3/KPS / app.*Send an encrypted App action over HTTP.
app_req_filekeyKPS / app.*Request the AppData filekey for an older Safe or a later re-unlock.
app_req_account_keyKPS / app.* APAn authorized AP recovers its Account Key.
app_get_dataKPS / app.*Read AppData metadata, ciphertext, and version.
app_set_dataKPS / app.*Write AppData ciphertext with version CAS.
app_updateKPS / app.*Update the current client display name.
app_delKPS / app.*Permanently revoke the current binding.
ping / pongKPS / current IDConnection verification and keepalive.
POST /v3/app_verifyJSON / service IPBackend verification of an App/Safe binding.
POST /v3/sso_user_pubkeyJSON / approved plugin IPApproved plugin backend reads a bound Safe signing key.
GET my.keylockr.app/svc_pk/{app_tag}JSON / publicThird-party backend service encryption key, independent of ownership.
GET my.keylockr.app/svc_info/{urlencoded_app_tag}JSON / publicPublic authoritative service title for third-party login pages.

app_scan_info, app_add, safe_*, and general file_* actions belong to the Mobile Safe authorization/data flow and are not called by third-party SSO/AppData clients.

Runnable Go sample

delegated.go is the recommended flow. It implements signed /start requests, a strict callback parser, offline KL assertion verification, the finish_secret check, and an atomic completion-store contract. The canonical fixture is shared by 9305, iOS, Android, and this example. main.go/kps.go/appdata.go retain only the legacy direct-client compatibility flow; delegated never falls back automatically.

# Delegated fixture and finish verification
env GOWORK=off go test ./...

# Legacy direct-client pure SSO compatibility example
go run . -app-tag YOUR_APP_TAG

# SSO + AppData read
go run . -app-tag YOUR_APP_TAG -app-data

# AppData read plus demo CAS write
go run . -app-tag YOUR_APP_TAG -app-data -write "hello"

# Restricted AP
go run . -app-tag YOUR_APP_TAG -ap -scopes 2fa

# Full AP
go run . -app-tag YOUR_APP_TAG -ap

The delegated example never calls app_verify. In the legacy compatibility command, app_verify succeeds only from a fixed egress IP allowed by server_ips. No example prints private keys, finish secrets, assertions, complete callbacks, the Account Key, filekeys, or decrypted data.

Pre-production completion checklist

Completing this checklist covers every protocol requirement needed to implement and ship independently.