Public demo API
What the core offers the public demo on prokura.network: a demo tenant with hard limits, passkey grants, read-only session tokens, a public evidence feed, statistics with a breach counter, hourly checkpoints and stateless verification. The policy gate (`packages/gate`) is the same one every tenant uses. None of this changes how it decides.
All amounts are decimal strings. Every error is JSON: `{ "error": "<code>", "message": "…" }`.
Who calls what
| Caller | Credential | Endpoints |
| -------------------------- | -------------------------------- | ------------------------------------------------------------------------------- |
| Website backend | Demo tenant API key (`Bearer …`) | `POST /v1/mandates`, `/v1/webauthn/challenges`, `/v1/passkeys`, `/v1/agents` |
| MCP server (the agent) | Demo tenant API key | `POST /v1/check`, `POST /v1/pay` |
| Visitor's browser | Session token (`Bearer pks_…`) | `GET /v1/session/evidence…` |
| Visitor's browser, anybody | None | `/v1/public/*`, `/v1/roots/*`, `GET/POST /v1/approvals/{id}…` (capability link) |The demo tenant API key belongs on the website's server. Never ship it to a browser. CORS (`PUBLIC_CORS_ORIGINS`) is open only on `/v1/public/*`, `/v1/session/*` and `/v1/roots/*`. All other endpoints send no CORS headers.
Demo tenant
Enable it with `DEMO_TENANT_ENABLED=true`. It works in production mode (`PRODUCTION=1`), where development pre-approval is off.
| Setting | Default |
| ----------------------- | ---------------------------------------------------------------------------------- |
| `DEMO_TENANT_ID` | `demo` |
| `DEMO_API_KEY` | none (seeded hashed at startup when set) |
| `DEMO_PRINCIPAL_DOMAIN` | `demo.prokura.network` (session principals are `did:web:s<session>.<domain>`) |
| `DEMO_STORE_DOMAINS` | `demo-store.prokura.network,demo-saas.prokura.network,demo-travel.prokura.network` |
Hard limits
These limits live in code (`apps/api/src/services/demo.ts`). The caller cannot raise them, and neither can configuration. A request outside them gets `422 demo_limit_exceeded` with a `violations` list.
| Limit | Value |
| ---------------------------------- | -------------------------------------------------------------------------------------------- |
| `scope.currency` | `EUR` |
| `capPerTx` (every currency entry) | ≤ 100.00 |
| `capPeriod` (every currency entry) | ≤ 150.00 per `P1D` |
| `validUntil` | ≤ now + 10 minutes, and in the future; `validFrom` not in the future |
| `payees` | domains on the demo store list only |
| `rails` | `mollie` with a Mollie **test** key (`test_…`), `x402` on a test network (`base-sepolia`, …) |
| `b2b`, sub-delegation | not available |
| Issuance rate | 5 per client per hour, counted in the database; `429` with `retry-after: 3600` |
| Grant method | `demo-unsigned` or `passkey`; `dev-preapproved` is refused |
The "client" is the `X-Demo-Client` header that the website backend sets to its own opaque, hashed visitor id. Without that header, the core uses the first `X-Forwarded-For` hop. Only a hash is stored.
The rail check runs at payment time as well. If the server's Mollie key becomes live after a demo mandate was issued, that mandate's payments are recorded with the error "not in test mode" and no rail is called.
Issue a demo mandate
POST /v1/mandates
Authorization: Bearer <DEMO_API_KEY>
X-Demo-Client: <hashed visitor id>
{
"agentId": "did:key:z6Mk…",
"validUntil": "2026-11-11T10:30:00Z",
"scope": {
"currency": "EUR", "capPerTx": "100.00", "fourEyesAbove": "50.00",
"capPeriod": { "amount": "150.00", "period": "P1D" },
"payees": [{ "type": "domain", "value": "demo-store.prokura.network" }],
"rails": ["mollie"], "purposes": ["software"]
},
"grantedBy": { "method": "demo-unsigned" }
}`principalId` and `grantedBy.name` are optional for the demo tenant only. Without `principalId`, the core creates an anonymous session principal. Its `legalName` and `grantedBy.name` default to `anonymous session`, and its DID carries an opaque session id. Response `201`:
{
"mandateId": "01M…",
"credential": { "…": "signed AgentAuthorityCredential" },
"principalId": "did:web:s3115a0be69e9590407.demo.prokura.network",
"sessionId": "3115a0be69e9590407",
"sessionToken": {
"token": "pks_…",
"mandateId": "01M…",
"expiresAt": "2026-11-12T10:30:00.000Z",
"scope": "read:evidence"
}
}Other tenants get `{ mandateId, credential }`, and `demo-unsigned` is refused for them (`403 demo_unsigned_not_allowed`).
Grant with a passkey
`grantedBy.method: "passkey"` is accepted only together with an assertion over a challenge that the core issued (`400 passkey_assertion_required` otherwise). Any tenant can use this flow.
1. `POST /v1/webauthn/challenges` `{ "principalId", "purpose": "register", "rpId": "prokura.network" }` returns `{ challengeId, options }` (WebAuthn creation options). 2. The browser runs `navigator.credentials.create(options)`. Then `POST /v1/passkeys` `{ "challengeId", "response" }` returns `{ passkeyId, rpId }`. 3. `POST /v1/webauthn/challenges` `{ "principalId", "purpose": "issue", "rpId": "prokura.network" }` returns `{ challengeId, options }` (request options). 4. The browser runs `navigator.credentials.get(options)`. Then `POST /v1/mandates` with `"grantedBy": { "method": "passkey" }` and `"passkey": { "challengeId", "response" }`.
Challenges are single-use, last 5 minutes and are bound to a tenant, a principal and a purpose. The core checks the origin against `WEBAUTHN_ALLOWED_ORIGINS` and the RP ID against `WEBAUTHN_RP_IDS`, for example `https://prokura.network`, `http://localhost:3000` and `https://approve.prokura.network`. RP IDs must be hostnames, not IP addresses.
Step-up approvals across origins
A step-up can be answered with a passkey created on any allowlisted RP, whether on the website or in the approval app.
- `GET /v1/approvals/{id}?rpId=prokura.network` returns the step-up details and WebAuthn request options for passkeys of that RP. `rpIds` lists the allowed RPs. `agentName` is shown as `{ value, selfDeclared: true }`. - `POST /v1/approvals/{id}/complete` `{ "response" }` approves. The gate re-runs, and on `allow` the rail executes. - `POST /v1/approvals/{id}/reject` `{ "response"? }` rejects. The core writes a `rejection` evidence record. The intent ends `deny` with `FOUR_EYES_REQUIRED` (the closed reason list gains nothing), and the approval's status becomes `rejected`. The assertion is optional. With it, the record keeps who rejected and `verifyNachweis` reports `outcome.rejectedBy`.
The evidence keeps the RP ID and origin that the assertion was actually made on, so an offline verifier checks it against the right RP.
Session token
`pks_…` is opaque, read-only, bound to one mandate and valid until `validUntil + 24 h`. Only its hash is stored. With it, and without any API key:
| Endpoint | Returns |
| -------------------------------------------- | --------------------------------------------------------- |
| `GET /v1/session/evidence` | `{ mandateId, records }`: the mandate's records, in order || `GET /v1/session/evidence/{id}` | one record of that mandate || `GET /v1/session/evidence/{id}/nachweis` | the signed `nachweis/v1` export || `GET /v1/session/evidence/{id}/nachweis.pdf` | the same as a PDF || `GET /v1/session/evidence/{id}/proof` | inclusion proof (daily root, else the hourly checkpoint) |Every other use returns `401` or `404`. That includes other mandates' records, write methods, the tenant endpoints and `GET /v1/mandates/{id}` with the full credential. Responses are `cache-control: private, no-store`.
Per-mandate segment (hash version 2)
New evidence records carry `v: 2`, `mandateSeq` (1, 2, 3, … per mandate) and `mandatePrevHash` (the previous record of the same mandate, or 64 zeros). These fields, together with `agentName`, are part of the record hash, in addition to the global `prevHash` chain. Records without `v` are hashed as before and still verify. A session's records therefore form a closed chain that the website can show and check on its own: `verifyMandateSegment(records)` in `@mandate/evidence`.
Public feed
`GET /v1/public/evidence?cursor=&limit=` lists demo-tenant records, newest first. `limit` runs from 1 to 100 (default 50); pass `nextCursor` back as `cursor`. The feed is read from the database and is `cache-control: public, max-age=60`.
{
"records": [
{
"hash": "…",
"prevHash": "…",
"kind": "pay",
"decision": "step_up",
"reasons": ["FOUR_EYES_REQUIRED"],
"rail": "mollie",
"createdAt": "2026-11-11T10:21:04.000Z",
"agentId": "did:key:z6Mk…",
"agentName": { "value": "Claude Desktop", "selfDeclared": true },
"currency": "EUR",
"amountBucket": "50-100",
"mandateSeq": 3,
"mandatePrevHash": "…"
}
],
"nextCursor": "MTI"
}The feed never carries the payee, an exact amount (buckets `0-10`, `10-25`, `25-50`, `50-100`, `100+`), the principal, the mandate id or the intent.
Statistics and the breach counter
`GET /v1/public/stats` (cacheable for 60 s):
{
"since": "2026-11-11T10:20:00.000Z",
"records": 42,
"decisions": { "allow": 20, "step_up": 9, "deny": 7, "total": 36 },
"breaches": 0,
"breachDefinition": "…",
"generatedAt": "…"
}`decisions` counts the gate decisions, approvals and rejections of the demo tenant since its first record. `breaches` is computed over the **whole** evidence chain (`apps/api/src/services/breaches.ts`). A breach is an `execution` or `rail_status` record whose rail settlement succeeded (a `rail_ref` and a status other than failed, canceled, expired or error) while the `pay` decision for that intent was not `allow` and no `approval` with decision `allow` preceded it in the chain. Each intent counts once. The expected value is 0. A test constructs the impossible case directly in the database to show that the counter catches it. Separately, `verifyNachweis` flags `rail executed without an allow` inside a single export.
Keys and verification
- `GET /v1/public/keys` returns the server's did:key and multibase public key, which sign evidence records, Nachweis exports, daily roots and checkpoints. - `POST /v1/public/verify` takes a Nachweis JSON body (at most 1 MB, 20 requests per minute per client). The call is stateless: nothing is stored.
{
"valid": true,
"serverKey": { "published": "did:key:z6Mk…", "inFile": "did:key:z6Mk…", "matches": true },
"exportSignature": "OK",
"links": [{ "id": "01M…", "kind": "pay", "hashVersion": 2, "hash": "OK", "signature": "OK" }],
"subject": { "id": "01M…", "kind": "pay", "decision": "step_up", "reasons": ["FOUR_EYES_REQUIRED"] },
"outcome": { "approvedBy": "…", "rail": "mollie", "railRef": "tr_…", "railStatus": "open" },
"errors": [],
"warnings": []
}Signatures are checked against the **published** key, not the key named in the file. For verification that trusts nobody, run `pnpm verify nachweis.json --server-key did:key:…` offline.
Roots for the live demo
Daily roots (`GET /v1/roots/{date}`) are published only after the UTC day ends. Hourly checkpoints fill that gap; see [merkle-roots.md](merkle-roots.md#hourly-checkpoints):
- `GET /v1/roots/checkpoints/latest` - `GET /v1/roots/checkpoints/{YYYY-MM-DDTHH}`
The API publishes them every minute for every finished hour (`ROOTS_AUTOPUBLISH`, on by default), along with yesterday's daily root.
Agent identity
`prokura_pay` and `prokura_check` (MCP) take an optional `agent_name` (at most 64 characters). The HTTP API takes it as `agentName` next to the signed intent in `POST /v1/check` and `POST /v1/pay`, so it is not part of the agent's signature. The core sanitises it (NFKC, no control characters, letters, digits and `. _ - : / ( ) @ + # '` only), stores it in the evidence record and shows it only as self-declared. It is never verified. `MCP_AGENT_NAME` sets a default for the MCP server.
Local test with the website
# core (this repo): demo tenant, website origins, MCP acting for the demo tenant cat >> .env <<'EOF' DEMO_TENANT_ENABLED=true DEMO_API_KEY=demo-api-key-change-me MCP_API_KEY=demo-api-key-change-me WEBAUTHN_RP_IDS=localhost WEBAUTHN_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:3000 PUBLIC_CORS_ORIGINS=http://localhost:3000 EOF docker compose up -d --build curl -s localhost:8080/v1/public/stats # website (../website): PROKURA_API_KEY=demo-api-key-change-me in .env.local, then pnpm dev # http://localhost:3000