REST API reference#
This page is a faithful mirror of spec/07 (API Contracts), the single source of truth. Docs restate the contract; they never diverge from it. If this page and spec/07 ever disagree, spec/07 wins and this page is the bug. The one addition is App authentication, whose source of truth is spec/20 — the same rule applies there.
Conventions. Transport is HTTPS/JSON unless noted. Auth is either a bearer session cookie / token (management routes) or public (the verify + tap paths). All timestamps are ISO-8601 UTC. All binary fields are hex-encoded lowercase.
Public verification#
The verify path is public, unauthenticated, and rate-limited. It never returns secret material and compares MACs in constant time.
GET /verify#
The SaaS-hosted JSON verifier — the target of a tag's SUN URL. Query params come from the URL scheme (spec/09): mirror mode uid, ctr, mac OR encrypted mode picc_data, cmac. sys is the system/customer id (or inferred from the host).
GET /verify?sys=<system>&uid=<uid>&ctr=<counter>&mac=<cmac>A genuine tap returns 200:
{ "authentic": true, "uid": "04a1b2c3d4e580", "read_ctr": 42,
"replay": "ok", "first_seen": false, "verified_at": "2026-08-01T12:00:00Z" }A forged, malformed, or replayed tap also returns 200 — with authentic:false and a machine-readable reason:
{ "authentic": false, "reason": "mac_mismatch" }reason is one of mac_mismatch | malformed | non_monotonic | unknown_uid.
Important
A forgery is a valid request with a false result, so it returns
200withauthentic:false— never a4xx. Verification is a fact about the tap, not a transport error. Handle the boolean, not the status code.
GET /t#
The human tap page — the NDEF URL target a phone opens. It runs the same verification as /verify, then:
- if the tap is authentic and the owner has stored a destination (system route,
G1, or a per-tag override,G5), it302-redirects to that destination (cache-control: no-store); - otherwise it renders the tap result page.
A forged, replayed, or unknown tap never redirects — the redirect target is owner-stored config only and is never taken from tag or URL input.
Management API (authenticated)#
All routes below require a bearer session token and are owner-scoped: a resource in another account returns 404 (which also hides its existence).
| Method | Path | Purpose |
|---|---|---|
| POST | /api/keys | generate a System Master Key (returns plaintext ONCE for DIY; id only for Hosted) |
| GET | /api/keys | list key metadata (id, version, tier, created, migration-window state) — never the key bytes |
| POST | /api/keys/{id}/rotate | rotate → new version + 90-day N/N-1 window; requires confirm:true |
| GET | /api/keys/{id}/stale-tags | tags still on an older key version + window state |
| GET | /api/tags | list/search the tag registry (?sys=<system>) |
| POST | /api/tags/import | bulk import a provisioning-app registry export (08) |
| GET | /api/tags/{uid} | tag detail (key version, last ctr, last seen) |
| PATCH | /api/tags/{uid} | dynamic tag update (Enterprise, G5): re-point / revoke / re-activate |
| GET | /api/download/diy | DIY package (core WASM + template + public config; NO master key) |
POST /api/keys#
Body: { "tier": "hosted" | "diy" }. DIY custody requires a perpetual Enterprise Edge license (402 license_required otherwise).
DIY response — the key bytes are returned exactly once and never stored server-side:
{ "key_id": "sys_01H…", "tier": "diy", "key": "00112233445566778899aabbccddeeff",
"key_version": 1, "warning": "shown once; not stored server-side" }Hosted response — key bytes are never returned; the server keeps an envelope-encrypted copy:
{ "key_id": "sys_01H…", "tier": "hosted", "key_version": 1 }Note
The
keyabove is a labeled test vector, not a real key. Never paste a production key into docs, tickets, or chats.
POST /api/keys/{id}/rotate#
Bumps the system to a new key version and, on Hosted, opens a 90-day migration window in which the verify path accepts key version N and N-1.
Warning
Rotation is consequential. Per-tag keys are derived from the system master key, so retiring a master key retires every tag derived from it. The window is a deadline, not a reprieve: every tag provisioned under the outgoing version must be physically re-provisioned before it expires, or it stops verifying and only re-writing the hardware brings it back. DIY systems keep no key bytes server-side, so they get no window at all — a DIY rotation stops those tags verifying at the bump.
Because of that, the call requires an explicit confirmation in the body — the same discipline POST /api/dsar uses for account deletion:
{ "confirm": true }confirm must be the literal boolean true. The string "true", 1, and an empty or missing body do not confirm.
Unconfirmed, the request is refused with 400 confirm_required, and the refusal names the blast radius so a caller can prompt with a real number instead of a placeholder:
{ "error": { "code": "confirm_required",
"message": "Every tag already provisioned under this key will stop verifying until it is re-provisioned. 12 tags are affected. Re-send with confirm:true to rotate.",
"affected_tags": 12, "window_days": 90 } }affected_tags counts the tags that verify today and are about to need re-provisioning: active tags whose key_version equals the system's current version, counted before the bump. A tag already left behind on an older version is not counted (an earlier rotation stranded it), and a revoked tag is not counted (verify already rejects it). When nothing is provisioned yet the count is 0 and the message reads No tags are provisioned under this key yet, so no tag will stop verifying.
window_days is the migration-window length in days (90), served by the API rather than hardcoded by the caller, so a confirm dialog states the same deadline the verify path enforces.
Note
Since the N/N-1 window shipped,
affected_tagsis the size of your re-provisioning backlog, not a count of tags that go dark at the bump. Those tags keep verifying forwindow_days— unless you rotate withretain_previous: false, in which case the count is literal and immediate.
Opting out of the window — retain_previous#
{ "confirm": true, "retain_previous": false }Rotate the key without retaining the outgoing one. The previous key material is cleared rather than stored, no window is opened, and every tag on the outgoing version stops verifying immediately. This is the right call when you are rotating because the old key is compromised — a 90-day grace period on burned key material is a regression, not a courtesy.
Only the literal boolean false opts out; anything else (including omitting the field) retains. The same call also closes a window already open: rotating again with retain_previous: false overwrites the retained slot with nothing.
A confirmed rotation returns 200 with the new version, the same count, and the window it opened:
{ "key_id": "sys_01H…", "key_version": 2, "affected_tags": 12,
"prev_key_version": 1, "prev_key_expires_at": "2026-11-04T10:15:00.000Z" }prev_key_version— the version still accepted during the window, ornullif none is retained.prev_key_expires_at— ISO-8601 UTC instant at which the previous key stops being accepted, ornull. After it passes, the retained material is ignored, not deleted: the fallback is gated on the clock, so the tap path never writes to your system row to expire it.
Both fields are null for a DIY system (no server-side key bytes to retain) and for any rotation sent with retain_previous: false. For DIY the response additionally carries the new key once, exactly as POST /api/keys does at DIY creation; a Hosted system never returns key bytes.
Only one previous version is retained. Rotating twice inside a window replaces the retained material with the key just retired — tags two versions back are covered by nothing.
Owner-scoped as usual: rotating a system in another account returns 404. The count, the wording, the window length, and the refusal envelope are shared source, so the Node portal and the Worker cannot drift on a consequential action.
GET /api/keys/{id}/stale-tags#
The re-provisioning backlog for one system: the active tags whose key_version is below the system's current version, with the window state that says which of them are still being carried and which have already gone dark. Owner-scoped (404 for a system in another account). Versions and timestamps only — no key material of any kind crosses this boundary.
Response 200:
{ "system_id": "sys_01H…", "key_version": 3, "prev_key_version": 2,
"window_open": true, "window_expires_at": "2026-11-04T10:15:00.000Z",
"window_days_remaining": 90, "window_days": 90,
"stale_count": 12, "truncated": false,
"tags": [
{ "uid": "04ee11ff2233aa", "key_version": 2, "sdm_mode": "encrypted_picc",
"product": "Limited Drop", "last_seen_at": "2026-08-05T09:00:00.000Z",
"provisioned_at": "2026-05-02T11:30:00.000Z", "still_verifying": true },
{ "uid": "04aa22bb3344cc", "key_version": 1, "sdm_mode": "encrypted_picc",
"product": "Limited Drop", "last_seen_at": null,
"provisioned_at": "2026-01-14T08:05:00.000Z", "still_verifying": false }
] }still_verifyingis the field that decides urgency. It istrueonly when the window is open and the tag's recordedkey_versionequalsprev_key_version— those tags work today and stop onwindow_expires_at. Everything else on this list has already stopped verifying, typically stranded by an earlier rotation, and needs re-writing now.window_days_remainingis whole days, and0once the window is closed.window_openisfalsefor a system that has never rotated, one rotated withretain_previous: false, and one whose window has expired — in all three cases nothing on this list is verifying.rotated_atis echoed from the system row: when the current version was issued.stale_countis the uncapped total;tagsis capped at 500 rows (most recent versions first), andtruncatedistruewhen the list was cut. Export the registry (GET /api/tags) for the full set.
A tag disappears from this list as soon as it is re-provisioned onto the current version — which is also the moment the previous key stops being accepted for it, because the registry's recorded key_version is the authority on which key a tag may be verified under, not merely a hint about which key to try. See Key management → Rotation for the operational picture.
The dashboard renders this endpoint behind the Stale tags button on any system past v1.
PATCH /api/tags/{uid} — Enterprise (mgmt_api)#
Re-point, revoke, or re-activate a provisioned tag without re-writing hardware. Body (at least one field required):
{ "route_url": "https://shop.example.com/tag/vip", "status": "revoked" }route_url: an absolutehttp(s)URL — a per-tag override that wins over the system route on an authentic/ttap.nullor""clears it.status:active | revoked.- Auth: session bearer token plus the
mgmt_apifeature gate. Owner-scoped: a tag in another account returns404. Non-Enterprise returns402 feature_required. - Revoke takes effect on the tap path immediately (verify already rejects a non-
activetag — a tag-row read, no billing coupling). Each call writes a non-secrettag_eventsaudit row.
Response 200:
{ "tag": { "uid": "04ee11ff2233aa", "system_id": "sys_…", "status": "revoked",
"route_url": "https://shop.example.com/tag/vip", "key_version": 1, "last_ctr": 1 },
"auth": "session_bearer+mgmt_api" }Custom URL routing — POST /api/systems/{id}/routing (Creator+, G1)#
Set or clear a system-wide tap destination. The entitlement is enforced here, at config time (Creator+), never on the hot tap path. The stored URL is owner input, validated as an absolute http(s) URL.
{ "url": "https://shop.example.com/products/limited-drop" }- Non-http(s) input →
400 validation; the bad value is never stored. null(or"") clears the route.- Not owned →
404. Below Creator →402 feature_required.
Response 200:
{ "system_id": "sys_…", "route_url": "https://shop.example.com/products/limited-drop" }Analytics — GET /api/analytics (Brand+, G3)#
Read-only aggregates over one system's verify events (?sys=<system>). Owner-scoped (404) and gated on geo_analytics (402 feature_required below Brand). The response carries only counts, ISO-2 country codes, and dates — no UID, IP, or personal field.
Response 200:
{ "system_id": "sys_…", "window_days": 30, "total": 128, "authentic": 120, "failed": 8,
"by_country": [ { "country": "US", "count": 90 }, { "country": "GB", "count": 30 } ],
"by_day": [ { "day": "2026-07-15", "count": 12 } ] }Multi-user / orgs (Enterprise, G6)#
An additive Enterprise layer: a user with no org is unaffected (personal ownership). All routes are gated on multi_user (402 feature_required) and enforce the owner | admin | member role model. Org resolution touches management and quota only — never the public tap/verify path.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/orgs | create an org; the caller becomes its owner + billing account |
| GET | /api/orgs/{id} | list members (any member) |
| POST | /api/orgs/{id}/members | add an existing user by email (owner/admin only) |
| POST | /api/orgs/{id}/systems | bring a personally-owned system into the org (owner/admin only) |
POST /api/orgs body { "name": "Acme Retail" } → 200:
{ "org_id": "org_…", "name": "Acme Retail", "role": "owner" }A non-member of an org receives 404 (the org's existence is hidden). A member without the owner/admin role on a management action receives 403 forbidden.
Billing (spec/13)#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/billing/status | current plan, limits, features, renews_at |
| POST | /api/billing/checkout | start a Stripe Checkout Session (plan or Edge license) |
| POST | /api/billing/portal | open the Stripe billing portal |
| POST | /webhooks/stripe | Stripe event sink (public, signature-gated) |
POST /api/billing/checkout body: { "plan": "creator" } or { "product": "edge_license" }. Both checkout routes return { "url": "https://checkout.stripe.com/…" }; card data never touches the EdgeNFC origin. /webhooks/stripe is public but signature-gated — an unsigned or invalid request never touches the DB and returns 400.
Hardware checkout — POST /api/checkout/hardware (I2)#
Buy Certified Blank NTAG 424 hardware (spec/16). Public — guest checkout is allowed (buying blanks needs no account); a bearer, if present, links the order to the buyer.
{ "items": [ { "sku": "ntag424-round-25mm", "qty": 100 } ] }- Empty/invalid items →
400 invalid_items. Billing unconfigured →503. - Returns
{ "url": "https://checkout.stripe.com/…" }— again, card data never touches our origin.
Compliance (spec/14)#
| Method | Path | Purpose |
|---|---|---|
| POST | /api/consent | record a cookie/ToS consent decision (public; may precede login) |
| GET | /api/consent | read back the latest cookie + tos decisions (authenticated) |
| POST | /api/dsar | DSAR export or delete (authenticated) |
POST /api/dsar body { "type": "export" } returns the account bundle (no key material — the response asserts contains_key_material:false). { "type": "delete", "confirm": true } erases the account; confirm:true is required (400 confirm_required otherwise).
App authentication (OAuth 2.0 Authorization Code + PKCE)#
The Android app signs in with OAuth 2.0 Authorization Code + PKCE. The app is a public client: no secret ships in the APK, so the PKCE proof-key is the only thing binding a token request to the app that started the flow. Everything below is enforced identically by both runtimes (the Node portal and the Cloudflare Worker) because the decision logic is shared source.
Registered client#
| Field | Value |
|---|---|
client_id | android |
redirect_uri | edgenfc://auth (the only registered value — exact match, no wildcards or prefixes) |
| Client type | public — no client secret, PKCE instead |
code_challenge_method | S256 only. plain is refused; a missing method is not defaulted |
Default scope | app |
Token lifetimes#
| Credential | Lifetime | Notes |
|---|---|---|
| Authorization code | 60 seconds | single-use; bound to client_id + redirect_uri + code_challenge |
| Access token | 15 minutes (expires_in is 900) | re-checked against the session row on every call, so a revoke kills it immediately |
| Refresh token | 30 days, absolute | rotated on every use; rotation re-issues inside the original window and never extends it |
| Entitlement cache | 300 seconds (cache_ttl_sec) | how long the app may cache an entitlement before re-checking |
Step 1: generate a PKCE verifier and challenge#
The code_verifier is 43–128 characters from [A-Za-z0-9-._~]. The code_challenge is base64url(SHA-256(ASCII(code_verifier))) — unpadded, so exactly 43 characters of [A-Za-z0-9-_] (RFC 7636 §4.2). Generate a fresh pair per authorization request and keep the verifier in memory only.
Shell:
# code_verifier — 32 random bytes, base64url, unpadded (43 chars, valid verifier alphabet)
VERIFIER=$(openssl rand 32 | openssl base64 | tr '/+' '_-' | tr -d '=\n')
# code_challenge = base64url(SHA-256(ASCII(verifier))) — unpadded
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr '/+' '_-' | tr -d '=\n')JavaScript / WebCrypto (the same transform the server performs):
const b64url = (b) =>
btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = b64url(new Uint8Array(digest));Step 2: GET or POST /api/oauth/authorize#
Open this in a system browser / Custom Tab, not a bare HTTP client: an unauthenticated GET returns an HTML sign-in page (200), whose form re-POSTs every parameter back to the same endpoint together with the account email. Nothing about a pending authorization is held in server state — both methods re-validate from scratch. GET reads query parameters; POST accepts application/x-www-form-urlencoded or application/json.
| Parameter | Required | Notes |
|---|---|---|
client_id | yes | must be a registered client (android) |
redirect_uri | yes | must match the client's allow-list exactly |
code_challenge_method | yes | must be the literal S256 |
code_challenge | yes | base64url SHA-256 digest, exactly 43 chars of [A-Za-z0-9-_] |
response_type | no (defaults to code) | any other value is rejected |
state | recommended | echoed back unchanged; max 512 chars; your CSRF binding |
scope | no (defaults to app) | [A-Za-z0-9 :_-], max 200 chars |
GET /api/oauth/authorize
?client_id=android
&redirect_uri=edgenfc%3A%2F%2Fauth
&response_type=code
&code_challenge_method=S256
&code_challenge=<challenge>
&state=<random-per-request>
&scope=appOn success the browser is 302-redirected back to the registered URI:
edgenfc://auth?code=<authorization_code>&state=<the-same-state-you-sent>Error delivery differs by severity. If the redirect target itself is untrustworthy — unknown client_id or an unregistered redirect_uri — the server answers locally with a 400 in the standard JSON envelope and performs no redirect at all (this is the open-redirect guard):
{ "error": { "code": "invalid_redirect_uri", "message": "redirect_uri is not registered for this client" } }Every other authorize error is delivered the OAuth way — a 302 back to the already allow-listed redirect_uri, carrying error, error_description and your state as query parameters (not the JSON envelope):
edgenfc://auth?error=invalid_request&error_description=code_challenge_method%3DS256%20is%20required&state=<the-same-state-you-sent>Step 3: POST /api/oauth/token#
Accepts application/x-www-form-urlencoded (the RFC 6749 default) or application/json. There is no client authentication — the code_verifier is the proof.
| Parameter | Grant | Notes |
|---|---|---|
grant_type | both | authorization_code or refresh_token |
client_id | authorization_code (required) | on refresh_token it is optional, but if sent it must match the session's client |
code | authorization_code | the code from step 2 |
redirect_uri | authorization_code | must be byte-identical to the one used at /api/oauth/authorize |
code_verifier | authorization_code | the verifier whose digest is the stored challenge |
device_id | authorization_code (optional) | opaque device label recorded on the app session |
refresh_token | refresh_token | the most recently issued refresh token |
curl -s https://edgenfc.com/api/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=android' \
--data-urlencode 'code=<authorization_code>' \
--data-urlencode 'redirect_uri=edgenfc://auth' \
--data-urlencode 'code_verifier=<code_verifier>' \
--data-urlencode 'device_id=<opaque-device-id>'Response 200 — identical shape for both grant types:
{ "access_token": "<app_access_token>", "token_type": "Bearer", "expires_in": 900,
"refresh_token": "<app_refresh_token>", "scope": "app" }Step 4: GET /api/app/entitlement#
The app's live plan/feature check. Authenticated with an app access token as a bearer; it is cheap by construction (indexed reads only, never a call to the payment provider).
curl -s https://edgenfc.com/api/app/entitlement \
-H 'authorization: Bearer <app_access_token>'Response 200 (values below are illustrative):
{ "plan": "creator", "status": "active",
"features": { "custom_routing": true, "custom_domain": false, "geo_analytics": false,
"webhooks": false, "mgmt_api": false, "multi_user": false },
"feature_list": ["custom_routing"],
"active_tag_limit": 2500, "active_tags": 42,
"edge_license": false, "cache_ttl_sec": 300 }plan is one of sandbox | creator | brand | enterprise. features is the full flag object; feature_list is the sorted list of the enabled flag names. Treat the server as authoritative and re-check after cache_ttl_sec seconds.
Step 5: refresh#
Rotation is mandatory: every successful refresh returns a new refresh token and immediately retires the one you presented.
curl -s https://edgenfc.com/api/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=<app_refresh_token>'Warning
Persist the new refresh token before you use the new access token. If you ever present a refresh token that has already been rotated out, the server treats it as a replay of a stolen copy and revokes the entire token family — see "Refresh rotation and reuse detection" below.
Step 6: POST /api/oauth/revoke#
Sign-out. Revokes the whole app session: the refresh token and every access token naming it.
curl -s -X POST https://edgenfc.com/api/oauth/revoke \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'token=<app_refresh_token>'The token may be either an access token or a refresh token, and may be supplied as token, refresh_token, or an Authorization: Bearer header. The response is always 200 — an unknown or already-revoked token returns the same body, so the endpoint is never a validity oracle (RFC 7009):
{ "revoked": true }App-auth error codes#
JSON errors use the same { "error": { "code", "message" } } envelope as the management API. The code values below are specific to the app-auth endpoints.
| Code | HTTP | Endpoint | Cause |
|---|---|---|---|
invalid_client | 400 (JSON, local) | authorize, token | unknown client_id |
invalid_redirect_uri | 400 (JSON, local) | authorize | redirect_uri not registered for the client |
invalid_request | 302 back to the client | authorize | code_challenge_method is not S256, malformed code_challenge, or state over 512 chars |
unsupported_response_type | 302 back to the client | authorize | response_type is anything but code |
invalid_scope | 302 back to the client | authorize | scope contains unsupported characters |
invalid_request | 400 (JSON) | token | code or redirect_uri missing |
invalid_grant | 400 (JSON) | token | code or refresh token unknown, expired, already used, bound to a different client/redirect, or PKCE verification failed |
unsupported_grant_type | 400 (JSON) | token | grant_type is neither authorization_code nor refresh_token |
unauthorized | 401 (JSON) | entitlement | missing, expired, wrong-audience, or revoked app access token |
Note
/api/oauth/tokenreturns one genericinvalid_grantmessage —"authorization code is invalid or expired"— for unknown, expired, replayed, mismatched and PKCE-failed codes. Do not branch on the message: an attacker must not learn which check failed, and neither will your error handling. The single exception is a detected refresh replay, which says"refresh token reuse detected; session revoked".
Security properties a client integrator must respect#
These are enforced server-side. They are listed here because a client that assumes otherwise will fail in production, not in testing.
S256 only#
code_challenge_method must be the literal S256. plain is rejected outright, and an absent method is rejected too — it is never defaulted to plain. The challenge must be an unpadded base64url SHA-256 digest (exactly 43 chars); anything else is refused before a code is minted.
Exact redirect URI, or no redirect at all#
redirect_uri is compared byte-for-byte against a hard-coded per-client allow-list. No wildcards, no prefix matching, no scheme-only checks, no trailing-slash tolerance. An unregistered value (or an unknown client_id) is answered with a local 400 and no Location header — nothing user-supplied is ever reflected into a redirect, because an open redirect here would hand an attacker the authorization code. The value you send to /api/oauth/authorize must be repeated byte-identically at /api/oauth/token.
Single-use, short-lived authorization codes#
A code lives about 60 seconds and is consumed atomically on first redemption, so two concurrent exchanges can never both succeed. It is bound to the client_id, redirect_uri and code_challenge it was issued with, and all of those are re-checked at the token endpoint. Codes are stored hashed, so a database read never yields a usable credential. Exchange a code immediately; never log it, cache it, or retry a redemption.
Refresh rotation and reuse detection#
Every successful refresh issues a new refresh token and retires the presented one — only the current token's hash exists server-side. Presenting a superseded refresh token is, by definition, either a replay or a stolen copy, so the server revokes the entire family: the app session is killed, its outstanding access tokens stop working on the next call, and both the attacker and the honest device are signed out.
Important
Do not retry a refresh with the old token. If a refresh request times out or the response is lost, you cannot safely resend the previous refresh token — if the server already rotated it, that retry is indistinguishable from a replay and kills the session. Write the new token to durable storage inside the same transaction that consumes the response, serialize refreshes so two threads never refresh at once, and on
invalid_granttreat the session as gone: discard both tokens and send the user through/api/oauth/authorizeagain.
Rotation never extends the 30-day absolute window that was set at sign-in. When that window expires, a full re-authorization is required regardless of how recently the app refreshed.
Audience separation#
App tokens and portal session bearers are different audiences and are not interchangeable, even though both are signed with the same key. An app access token presented to a management route is rejected as unauthenticated, and a portal session bearer presented to /api/app/entitlement is rejected with 401 unauthorized ("app access token required"). Never pipe a token from one surface into the other; obtain each from its own flow.
Access tokens are re-validated against the app-session row on every request, so a revoke — whether from /api/oauth/revoke or from reuse detection — takes effect on the next call rather than at the 15-minute expiry.
Error envelope (management API)#
Every management error uses one envelope; messages are non-secret and safe to display:
{ "error": { "code": "unauthorized", "message": "login required" } }code is one of unauthorized | not_found | rate_limited | validation | feature_required | license_required | quota_exceeded (plus the hardware/billing codes above: invalid_items, billing_unavailable, confirm_required, bad_signature, forbidden).
Provisioning-app sync (optional)#
POST /api/tags/import accepts the registry export schema (spec/08), idempotent by uid. The provisioning app authenticates with a scoped provisioning token — not the master key.
Core FFI (in-process, not HTTP)#
Two non-HTTP surfaces exist alongside the REST API: the Kotlin app ↔ Rust core bridge (diversify_key, verify via UniFFI/C-ABI) and the portal frontend ↔ Rust core WASM verify used for the DIY demo/self-test. See spec/07 §4 and spec/05.
EdgeNFC