docs

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