Facilitator API reference

Facilitator HTTP API

Base URL (production): https://txpass.app. GET /v1/protocol reports delegation.audience as https://txpass.app. First publisher example: imprintai-api (ImprintAI Provenance API). A dogfood setup is an operator running a facilitator against their own API to try the rail before anyone else, not a special Imprint mode. Where this page names Imprint, that is the first publisher on the rail.

This documents what the facilitator actually does today, including short-lived session JWT minting.


Auth overview

ModeWhenClient proves identity how
brc103TXPASS_SERVER_PRIVATE_KEY set@bsv/sdk AuthFetch → mutual auth (BRC-103 / BRC-104). Handshake POST /.well-known/auth. Server sets req.auth.identityKey (compressed pubkey hex).
stubOnly while an operator tries the facilitator against their own API before anyone else — not productionHeader x-stub-identity: <compressed-pubkey-hex> — NON-PRODUCTION

Identity representation: compressed secp256k1 pubkey, lowercase hex, 66 chars (02/03 + 32-byte X).

Publisher must not trust a client-supplied tier or quota.

Issuer configuration: Set TXPASS_ISSUER=https://txpass.app explicitly on the facilitator and publisher (full URL, no trailing slash). It must match the publisher's expected JWT iss exactly.

Only trust facilitator entitlement fields listed below, then map plan → tier locally.


Browser CORS (Yours / AuthFetch)

Cross-origin browser clients (a publisher's web UI, and a wallet such as Yours.org) require CORS before BRC-103 auth.

Allowed browser origins are a list for the facilitator. Today that list is the TXPASS_CORS_ORIGINS setting (comma-separated). A hosted facilitator can keep the same list per product. The list is required in production. Each entry must match the browser Origin exactly: scheme and host, no path. A non-default port is part of that Origin and must match; do not add a path or a trailing slash. Example: https://staging.api.example

When unset in non-production, only common localhost origins are allowed (no arbitrary reflect). Production with unset deny browser Origin.

CORS runs before express.json / auth:

  1. OPTIONS — explicit handler (never bare 204). Allowlisted Origin → 204 with Access-Control-Allow-Origin (request Origin, never *), Vary: Origin, Allow-Credentials: true, Allow-Methods: GET, POST, OPTIONS, Allow-Headers (static AuthFetch / x-bsv-auth* list plus any Access-Control-Request-Headers), and Expose-Headers for the x-bsv-auth* family. Missing Origin → 204 without ACAO. Present but not allowlisted → 204 without ACAO (auth never runs). Origin must match the allowlist exactly.
  2. Non-OPTIONS (POST /.well-known/auth, POST /v1/session, …) — same allowlisted Origin gets Allow-Origin + Expose-Headers + Vary + credentials on the real response.

Credentials: true (never combined with Allow-Origin: *). Preflight never returns AuthProtocolError 400.

Handshake: POST /.well-known/auth must be reachable from the allowlisted Origin after preflight and the real response must itself carry CORS headers.

Entitlement 402 vs BRC-105 micropayment

POST /v1/session returns 402 with JSON { "error": "payment_required", ... } when identity is authenticated but there is no live entitlement and the product policy is on_new_identity=reject (or auto-enroll failed). The facilitator does not send x-bsv-payment-* (BRC-105) headers on that response, so AuthFetch will not treat it as a micropayment challenge.

New-identity policy (reject | auto_enroll)

Publishers configure how brand-new wallet identities are handled when AuthFetch succeeds but no entitlement exists yet:

ModeBehavior
reject (safe default for unknown products)402 payment_required — message mentions subscribe or that auto-enroll is disabled
auto_enrollFacilitator creates a durable free entitlement (not on-chain) and mints the session with plan: "free"

Product fields (plan table / @txpass/protocol):

  • on_new_identity: 'reject' | 'auto_enroll'
  • auto_enroll_plan: plan slug (usually 'free')
  • Free plan: slug: 'free', price_sats: null, quota: { per_day, per_month }, long duration_days (~10y scaffold)

Env overrides (change quotas or new-identity policy without redeploying code):

EnvRole
TXPASS_ON_NEW_IDENTITYreject | auto_enroll
TXPASS_AUTO_ENROLL_PLANdefault free
TXPASS_FREE_QUOTA_PER_DAY / TXPASS_FREE_QUOTA_PER_MONTHpublisher free quotas
TXPASS_PRODUCT_CONFIGoptional JSON override

Code default for the first publisher, imprintai-api: auto_enroll + free quotas 50/day · 1000/month. That is how this product was configured when the operator tried the rail on their own API; other products do not inherit it. Production publishers should set reject or tighter quotas.

Auto-enroll rows use a stable synthetic receipt_txid (autoenroll:<sha256…>) and optional source: "auto_enroll". Second session mint reuses the same row (no duplicates). Paid subscribe later upserts a separate on-chain receipt; live selection prefers paid/on-chain over free auto-enroll.

Policy is exposed lightly on GET /v1/protocol under newIdentity (on_new_identity, auto_enroll_plan, free_quota) and per product on GET /v1/products (new_identity). It is no longer on GET /health.

Rate limits (all routes)

In-process token buckets (apps/facilitator/src/rate-limit.ts). Each bucket allows a burst of N requests and refills at N per window (default window 60 s). Keys: client IP, and on protected routes also the authenticated identity key (after AuthFetch / BRC-103). Over the limit:

HTTP/1.1 429 Too Many Requests
Retry-After: 12

{ "error": "rate_limited", "message": "Too many requests (session, per identity). Retry after 12s.", "bucket": "session", "scope": "identity", "retry_after": 12 }

retry_after repeats Retry-After in the body because AuthFetch (BRC-103) callers only see signed x-bsv-* headers; @txpass/client requestSession returns { ok: false, status: 429, error: "rate_limited", body: { retry_after } }. Protected-route 429s are signed responses (limiters run after the auth middleware). A 429 on the handshake itself (POST /.well-known/auth) is unsigned, so @bsv/sdk AuthFetch throws an error containing Received HTTP 429 … rate_limited. Browsers can read Retry-After (it is in Access-Control-Expose-Headers).

BucketRoutesPer IP (env …_PER_IP)Per identity (env …_PER_IDENTITY)
handshakePOST /.well-known/auth120—
publicGET /health, GET /v1/protocol, GET /v1/pricing, GET /v1/receipts/issuer, GET /v1/products, GET /v1/session/jwks, GET /account*300—
introspectPOST /v1/session/introspect120—
sessionPOST /v1/session (incl. delegated)3020
receiptsPOST /v1/receipts2010
publisher_receiptsPOST /v1/receipts/issue, /sync, /revoke (publisher-signed)120120
passkey_backupPOST /v1/account/backup, POST /v1/account/backup/delete (only with TXPASS_PASSKEY_BACKUP)105
passkey_restorePOST /v1/passkeys/backup/restore (unauthenticated; only with TXPASS_PASSKEY_BACKUP)10—
root_holderPOST /v1/account/root-holder/activate (only when root-holder gate is on)105
device_requestPOST /v1/devices/requests, POST /v1/devices/requests/lookup (only with TXPASS_DEVICE_DELEGATION)105
device_writedevice approve / deny / revoke / renew, POST /v1/account/session, invite cancel2010
device_invitePOST /v1/devices/invites (/pair-device)105
device_invite_peekPOST /v1/devices/invites/peek (unauthenticated)20—
pair_checkPOST /v1/pair/check (unauthenticated; only with TXPASS_PAIR_POPUP)60—
pair_codePOST /v1/pair/codes (signed by the user)2010
pair_tokenPOST /v1/pair/token (unauthenticated; publisher backends)60—
pro_enrolPOST /v1/account/pro/enrol (free Pro enrol, testing phase; plus one enrolment at a time per identity)63
delegation_writePOST /v1/delegations, POST /v1/delegations/:id/revoke, agent-policy writes (POST /v1/policies, …/admissions, …/remove, …/confirm, …/revoke), connect approvals (POST /v1/connect/requests/:id/approve, …/deny)2010
enrollPOST /v1/policies/:id/enroll (plus 30/hour per policy and client source, 300/hour per policy; lockout after 10 failed codes per source or 100 per policy in an hour)1010
connectPOST /v1/connect/requests (plus at most 3 pending requests per agent key and 10,000 in total)105
readGET /v1/admin/*, GET /v1/publisher/products, GET /v1/delegations, GET /v1/entitlements, GET /v1/account/entitlements, GET /v1/account/pro, GET /v1/account/backup, GET /v1/account/root-holder, GET /v1/policies*, GET /v1/connect/requests/:id (agent polling)6030

Env names: TXPASS_RATE_LIMIT_<BUCKET>_PER_IP / TXPASS_RATE_LIMIT_<BUCKET>_PER_IDENTITY with BUCKET upper-cased (e.g. TXPASS_RATE_LIMIT_SESSION_PER_IP=30, TXPASS_RATE_LIMIT_DELEGATION_WRITE_PER_IDENTITY=10); 0 = unlimited for that key. TXPASS_RATE_LIMIT_WINDOW_SECONDS (default 60), TXPASS_RATE_LIMIT_ENABLED (default on; 0 disables all limits). A malformed value refuses startup.

Client IP (IPv6 grouped by /64): X-Forwarded-For is used only when TXPASS_TRUST_PROXY is set (Express trust proxy: 1 = one proxy hop, true, or an IP/CIDR list). Unset → socket address; a client-sent X-Forwarded-For is ignored. Railway: set TXPASS_TRUST_PROXY=1 (otherwise every client shares the Railway proxy address and one bucket). If logs warn that the resolved client IP is private/CGNAT, the hop count is too low — try 2 or a CIDR list of the proxy ranges.

CORS preflight (OPTIONS) is never limited and never consumes tokens.

Limits are per process: they reset on every deploy/restart and are not shared across replicas. That is fine for the single Railway instance; move them to a shared store before running more than one replica.

Throwaway /demo limits

POST /v1/session is unchanged for normal clients. The public /demo page adds "demo": true to that body (enrol and the later agent mint only; not connect). AuthFetch cannot send an arbitrary header, so the marker is a JSON field. Marked requests also count against the session bucket above, then these stricter caps:

CapDefaultEnv
Demo enrols (no policy / delegation) per client IP5 per hourTXPASS_DEMO_ENROLL_PER_IP
Demo session mints (policy or delegation) per client IP5 per hourTXPASS_DEMO_SESSION_PER_IP
Marked sessions per demo identity1, no refill until restartTXPASS_DEMO_SESSIONS_PER_IDENTITY
IP window3600 sTXPASS_DEMO_LIMIT_WINDOW_SECONDS

0 disables that cap. TXPASS_RATE_LIMIT_ENABLED=0 disables these with the other limits. Over the cap: 429 rate_limited with bucket demo_enroll, demo_session, or demo_identity. Client IP is req.ip (IPv6 /64); X-Forwarded-For counts only when TXPASS_TRUST_PROXY is set. Counters are in-memory per process, same as the other limits (not Postgres). Private keys are not stored. A marked session TTL is capped at 15 minutes. Omitting demo: true leaves Imprint free auto-enrol on the normal session limits.

Public routes (no auth)

Also public: GET /v1/session/jwks, POST /v1/session/introspect (see session section below).

GET /health

Liveness probe (the Railway healthcheck). Exactly:

{ "ok": true, "version": "0.1.0" }

HTTP 200 whenever the process is serving (it only starts listening after its stores are initialised; no query is run per request). Nothing else is returned: store backends, the session-key source, delegation and new-identity policy are on the admin-only . The delegation aud is on GET /v1/protocol. No response carries X-Powered-By.

GET /v1/admin/health (admin)

Detailed diagnostics for Txpass admins. Same gate as GET /v1/admin/products: a BRC-103 (AuthFetch) signed request from an identity key listed in TXPASS_ADMIN_KEYS. Unsigned → 401 unauthorized; signed by any other key → 403 admin_only. Neither error carries any diagnostics. With TXPASS_ADMIN_KEYS unset nobody can call it. Rate-limit bucket read; Cache-Control: private, no-store.

{
  "ok": true, "service": "txpass-facilitator", "version": "0.1.0",
  "authMode": "brc103", "originator": "txpass.app", "issuer": "https://txpass.app",
  "sessionKeySource": "TXPASS_SESSION_SIGNING_KEY",
  "store": "postgres", "delegationStore": "postgres", "policyStore": "postgres", "connectStore": "postgres", "publisherStore": "postgres",
  "delegation": { "allow_delegation": true, "audience": "https://txpass.app" },
  "newIdentity": { "product": "imprintai-api", "on_new_identity": "auto_enroll", "auto_enroll_plan": "free", "free_quota": { "per_day": 50, "per_month": 1000 } }
}

sessionKeySource names where the session signing key comes from (an env var name, never the key). The *Store fields name the storage backend (memory or postgres); no query is run for them.

GET /v1/protocol

Machine descriptor from @txpass/protocol (buildProtocolDescriptor()): prefix, version, originator (txpass.app), protocolId, keyIds, feeSats, commitmentDefault, verifierPackage, docsUrl, and delegation (version, protocolId, keyId, counterparty anyone, implemented/reserved issuer types, limits, transport, and audience: the exact aud a delegation cert or agent policy must carry for this facilitator, i.e. its JWT issuer string; the account page and txpass-agent read it here). delegation.allow_delegation per product is on GET /v1/products.

GET /v1/pricing

Public receipt config: which seller (publisher) keys and plans (durations) each product has, the optional legacy pay-to scripts, and the Txpass receipt issuer keys. This is the same data another operator needs to reproduce the verification answer. Paid plan duration_days must be 30..3660 (monthly or longer); short agent sessions mint from that entitlement. Optional legacy price_sats may appear for diagnostics; it is unused for verify. Txpass never handles billing: publishers bill their customers themselves and ask Txpass to record the subscription (see Publisher-issued receipts).

{
  "status": "configured",
  "products": {
    "imprintai-api": {
      "product": "imprintai-api",
      "sellers": ["02…"],
      "payToLockingScriptsHex": ["76a914…88ac"],
      "plans": { "pro-month": { "slug": "pro-month", "price_sats": null, "duration_days": 30 } },
      "grace_seconds": 86400
    }
  },
  "min_confirmations": 1,
  "max_clock_skew_seconds": 600,
  "receipt_format": "…",
  "receipt_issuance": "configured",
  "receipt_issuers": ["03…"]
}
  • payToLockingScriptsHex may be empty: publisher-issued receipts carry no payment output.
  • receipt_issuance is configured or not_configured (no issuer key on this facilitator). receipt_issuers lists the accepted Txpass receipt issuer public keys (current first, then retired ones), the same list as GET /v1/receipts/issuer.

status is one of:

  • configured
  • dev_defaults (non-production placeholders)
  • not_configured / invalid (production: paid ingest is closed with 503). The response then also carries a human-readable note and products: {}; this is the normal state when publishers bill off-chain.
  • disabled

GET /account (end-user page)

Static page for end users: wallet connect, Access (from GET /v1/account/entitlements), agents (delegation v1 routes), and a paid-receipt panel shown only when /v1/pricing is configured. Plain HTML/TS, same origin, no cookies. Files: apps/facilitator/account-page/ (HTML/CSS committed; dist/account.js built by the facilitator build). Assets: /account/account.js (SRI-pinned in the HTML), /account/account.css, /account/config.json ({ "dev": false } in production; { "dev": false, "passkeys": true } when TXPASS_PASSKEYS is on, which also adds require-trusted-types-for 'script'; trusted-types 'none' to the page CSP). With passkeys on, users can sign in with a Txpass ID: a key created in their browser and unlocked by a passkey (WebAuthn PRF); it signs the same BRC-103 requests as a wallet, so no API route changes.

Headers: Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' http://localhost:3301 http://localhost:3321 https://localhost:2121; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none', Cache-Control: no-store, X-Frame-Options: DENY, Referrer-Policy: no-referrer, nosniff. The connect-src localhost entries are the local BRC-100 wallet endpoints WalletClient('auto') probes. Local run against production: account-page-manual-test.md.

GET /account/connect?r=<id> serves the same page (same CSP and SRI) and opens the connect approval panel for request id.

Public site: GET /, /about, /pricing, /docs, /docs/:page

Marketing and documentation pages, prebuilt as static HTML by the facilitator build (node scripts/build-public-site.mjs; sources in apps/facilitator/public-site/, docs rendered at build time from repo Markdown listed in public-site/docs-manifest.mjs). Only paths listed in the built routes.json are served, plus /assets/* (the SRI-pinned site.css / site.js, favicon and SVG diagrams). No cookies, no third-party resources, in the public rate-limit bucket.

GET / is content-negotiated: it serves the landing page only when Accept includes text/html (every browser). Any other client (JSON tooling, AuthFetch, curl with the default */*) gets the same 404 as before; / has no API meaning. Responses carry Vary: Accept.

Headers: Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none', Cache-Control: no-cache, X-Frame-Options: DENY, Referrer-Policy: no-referrer, nosniff. A missing build answers 503 with a plain-text hint.

GET /publisher (publisher dashboard page, early access)

Static page for publishers, early access (guide). Same pattern as /account: plain HTML/TS, same origin, identical CSP and security headers, SRI-pinned bundle, no cookies, no innerHTML. Files: apps/facilitator/publisher-page/ (HTML/CSS committed; dist/publisher.js built by the facilitator build via scripts/build-account-page.mjs, which now bundles both pages). Assets: /publisher/publisher.js (SRI), /publisher/publisher.css, plus the shared /account/account.css. Every API call is a BRC-103 request signed by the connected wallet (Yours / BRC-100), against the routes in Publisher dashboard routes.

GET /v1/products

Public catalog of the products this facilitator serves (no auth; Cache-Control: public, max-age=60). Routes come from @txpass/protocol PRODUCT_CATALOG unless the product's owner has saved a copy in /publisher (see Publisher dashboard routes). With no saved copy the response is the code catalog rendering (plus api_origins, below). A saved copy of a built-in product replaces only the display copy (label, access, recommended_scopes) and adds routes[].group (P6, additive; ungrouped → "Other", unlabelled → the raw METHOD /path); its route set stays the code one. Products requested in /publisher appear only after a Txpass admin approves them (appended after the built-ins); pending_review products are never listed. TXPASS_CONFIG_SOURCE=env ignores saved copies (kill switch), and a publisher-store read error falls back to the code catalog. routes[].method + pathPattern is exactly the default session JWT scope, and the superset any delegation scope must fit in; label and access are display-only and never go into tokens or certs. access is read (looks something up) or write (creates or changes something on the publisher side). new_identity / delegation reflect the live product policy (env overrides included); paid.open is true when paid receipts are accepted for this product, with the plan slugs (details and prices in GET /v1/pricing). recommended_scopes (additive, always present) is the publisher's recommended default scope for agents, in route order; [] means no recommendation. txpass-agent connect without --scopes requests exactly this set, and the /account policy and agent scope pickers pre-tick it (nothing is pre-ticked when it is empty).

api_origins (additive, always present) lists the bare API origins (scheme://host[:port], https; http://localhost only for dev) the publisher registered: the only places an agent sends this product's session tokens. @txpass/agent ≥ 0.1.4 uses it as its allowlist (plus any local api_bases). Source: the product's DB row (products.api_origins) when non-empty, else the built-in seed (imprintai-api → https://imprintai.link, overridable per product with TXPASS_PRODUCT_API_ORIGINS), else []. A product requested in /publisher defaults to the origin of its API base URL.

docs_url (additive, always present; null when unset) is the publisher's public API docs link (https). examples (additive, always present; [] when none) is the publisher's example requests for agents: { id, method, path, description, body?, notes?, fields? }, where path is a path pattern on an api_origins host and body is a minimal JSON body. Display/example data only: it never changes token scope or origins. Source: the DB row when set (products.docs_url non-null, products.examples non-empty), else the built-in seed (imprintai-api: create → POST /api/imprint {"model":"txpass-test","type":"created","async":true}, status → GET /api/status/:jobId), else null / []. txpass-agent describe and the MCP txpass_describe tool (@txpass/agent ≥ 0.1.5) render it; the agent brief shows it under Calling the API.

{
  "products": [
    {
      "product": "imprintai-api",
      "name": "ImprintAI Provenance API",
      "routes": [
        { "method": "POST", "pathPattern": "/api/imprint", "label": "Create an imprint (anchor content provenance)", "access": "write" },
        { "method": "GET", "pathPattern": "/api/status/:jobId", "label": "Check the status of your imprint job", "access": "read" },
        { "method": "POST", "pathPattern": "/api/log", "label": "Write a provenance log entry", "access": "write" },
        { "method": "POST", "pathPattern": "/api/metadata", "label": "Store provenance metadata", "access": "write" },
        { "method": "POST", "pathPattern": "/api/label", "label": "Create a provenance label", "access": "write" },
        { "method": "POST", "pathPattern": "/api/c2pa/label", "label": "Create a C2PA label", "access": "write" },
        { "method": "POST", "pathPattern": "/api/label/finalize", "label": "Finalize a label", "access": "write" },
        { "method": "GET", "pathPattern": "/api/metadata/workflow/:workflowId", "label": "Read metadata for a workflow", "access": "read" },
        { "method": "GET", "pathPattern": "/api/metadata/:id", "label": "Read a metadata record", "access": "read" }
      ],
      "recommended_scopes": [
        { "method": "POST", "pathPattern": "/api/imprint" },
        { "method": "GET", "pathPattern": "/api/status/:jobId" }
      ],
      "api_origins": ["https://imprintai.link"],
      "docs_url": null,
      "examples": [
        { "id": "create", "method": "POST", "path": "/api/imprint", "description": "Create an imprint …", "body": { "model": "txpass-test", "type": "created", "async": true }, "notes": ["…"], "fields": [{ "name": "model", "type": "string", "required": true }] },
        { "id": "status", "method": "GET", "path": "/api/status/:jobId", "description": "Check an imprint job …" }
      ],
      "new_identity": { "on_new_identity": "auto_enroll", "auto_enroll_plan": "free", "free_quota": { "per_day": 50, "per_month": 1000 } },
      "delegation": { "allow_delegation": true },
      "paid": { "open": true, "status": "configured", "plans": ["pro-month", "pro-year"], "pricing": "/v1/pricing" }
    }
  ]
}

GET /v1/products/:slug

Public product document for one slug (Cache-Control: no-store). Same route list as that product on GET /v1/products (method, path, label, read/write, and group when the publisher saved copy). Also returns api_origins, docs_url and examples (same as on GET /v1/products), the live new-identity policy and the staging trial from the dashboard form (one host + the routes that were ticked).

A built-in product and a live product are always returned. A product still waiting for review is returned when you already know the slug, so a publisher's agent can read it before approval. It is still omitted from GET /v1/products. Unknown slug → 404 product_not_found.

Free enrolment is blocked unless this product's policy is auto_enroll. Verification of a session token is free. Publishers bill their own users. Calls made with a Txpass session still count toward that user's existing limits. There is no price block on this document.

When the product has no single staging host, staging.origin is null and staging.note is The publisher has not set a staging host. The server does not invent a host.

{
  "product": "imprintai-api",
  "name": "ImprintAI Provenance API",
  "routes": [ { "method": "POST", "pathPattern": "/api/imprint", "label": "Create an imprint (anchor content provenance)", "access": "write" } ],
  "recommended_scopes": [ { "method": "POST", "pathPattern": "/api/imprint" } ],
  "free_enrolment_default": "blocked",
  "new_identity": { "on_new_identity": "auto_enroll", "auto_enroll_plan": "free", "free_enrolment": "auto_enroll" },
  "staging": { "origin": null, "routes": [], "note": "The publisher has not set a staging host." },
  "verification": "free",
  "users_billed_by": "publisher",
  "session_calls": "count_toward_the_users_existing_limits",
  "agent_brief": "/v1/adopt/imprintai-api/agent-brief.md"
}

imprintai-api above is the first publisher on the rail, not a special mode. Its current policy is the one in the plan table (auto_enroll). A product with no opt-in reports free_enrolment: "blocked" and on_new_identity: "reject".

GET /v1/adopt/:slug/agent-brief.md

Public filled prompt (text/markdown, Cache-Control: no-store) for the same slug. Paste it into a coding agent. Facilitator URL, product slug, JWT issuer, staging origin and routes are already substituted. The body does not contain raw {{placeholders}}.

The staging section comes from the dashboard form. No staging host → the brief says the publisher has not set a staging host, and tells the agent not to invent one. Unknown slug → 404 product_not_found.

GET /.well-known/txpass.json

Discovery pointer only. Not a product and not a catalog.

{
  "facilitator": "https://txpass.app",
  "product_document": "https://txpass.app/v1/products/{slug}",
  "agent_brief": "https://txpass.app/v1/adopt/{slug}/agent-brief.md"
}

Protected routes

Require AuthFetch session (or stub header in stub mode). Session mint: POST /v1/session (see below).

Passkey identity backup (TXPASS_PASSKEY_BACKUP, with TXPASS_PASSKEYS)

Mounted only when both flags are on (otherwise 404). Txpass stores txpass/v1/wrapped-identity blobs it cannot decrypt: AES-GCM under HKDF-SHA256(PRF(passkey, sha256("txpass/v1/backup-prf-salt")), info "txpass/v1/identity-wrap"), computed only in the user's browser. Passphrase recovery files are never uploaded.

  • GET /v1/account/backup (BRC-103 identity) → { ok, identity, backups: [{ credential_id, created_at, updated_at }] } (no blobs).
  • POST /v1/account/backup (BRC-103 identity) { credential_id, lookup, blob } → 201 stored / 200 replaced. lookup is 32 bytes base64url (HKDF(same PRF, info "txpass/v1/backup-lookup")); only sha256(lookup) is stored. The blob must name the signing identity and credential_id, use the backup salt, and wrap exactly 32 bytes. Errors: 400 invalid_request / invalid_backup, 409 lookup_conflict (that passkey already backs up another identity), 409 too_many_backups (10 per identity).
  • POST /v1/account/backup/delete (BRC-103 identity) { credential_id? } → { ok, deleted }; only the caller's own rows.
  • POST /v1/passkeys/backup/restore (no auth) { lookup } → { ok, blob } or 404 backup_not_found. Only the passkey yields the lookup value.

Root holder (ADR 0003 slice 9)

Mounted when TXPASS_PASSKEY_BACKUP and/or TXPASS_DEVICE_DELEGATION is on (otherwise 404). One live holder_id per passkey identity so backup restore / recovery-file import cannot leave two principals active (Pro-gate bypass). Wallet roots (Yours / BRC-100) never register a row and are exempt.

Holder proof: signed header x-bsv-txpass-root-holder: <holder_id> (AuthFetch signs every x-bsv-* header, same pattern as team x-bsv-txpass-act-for). When a live row exists for the signing identity and the signer is a bare root (not a device, not team act-for), missing / unknown / superseded proof → 403 root_holder_superseded on account routes (including session mint and root_only). Device and team signers are unaffected. Activate is exempt from the proof check (it registers the holder).

  • POST /v1/account/root-holder/activate (BRC-103 as root I) { holder_id, source: "create"|"restore"|"recovery_file" } → { ok, identity_key, holder_id, source, activated_at, previous_holder_id? }. holder_id is base64url, 16–64 bytes. Same holder_id is idempotent; a different one supersedes the prior. Rate limit: root_holder. Errors: 400 invalid_request, 409 holder_id_conflict, 403 root_only (device / team).
  • GET /v1/account/root-holder (BRC-103 as root I + live holder proof) → live metadata, or 403 root_holder_superseded, or 404 root_holder_not_found.

Device delegation (TXPASS_DEVICE_DELEGATION)

Mounted only when the flag is on (otherwise 404, and certs are checked with the 0.2 rules). A device key D acts for a root identity I through a txpass-account delegation cert (admin scopes, 2 years, renewable; see delegation v1 §2). Rate limits: device_request (10/IP, 5/identity per minute), device_write (20/IP, 10/identity per minute).

  • POST /v1/devices/requests (D) { label?, prf? } → 201 { id, user_code, status, expires_at, interval }. 10 minutes, 3 pending per key. 409 device_already_added / device_key_is_root, 429 too_many_pending.
  • GET /v1/devices/requests, GET /v1/devices/requests/:id (D) → status; once approved also identity_key, device, delegation (the cert). Others → 404.
  • POST /v1/devices/requests/lookup (I, or a device with admin:devices.approve at depth 1) { user_code } → { id, device_key, label, prf, expires_at, identity_key, max_scope, approver }; 402 txpass_pro_required without live Pro on I; 409 device_self_approval; 404 request_not_found.
  • POST /v1/devices/requests/:id/approve (same callers) { delegation } → 201 { ok, identity_key, device }. The cert must be signed by the caller for the request's key with an admin scope (a device approver: parent = its cert, scope within its own). Signature checked first, then 402 txpass_pro_required. Single use.
  • POST /v1/devices/requests/:id/deny (any authenticated identity) → { ok, status: "denied" }.
  • GET /v1/account/devices (I or device) → { identity_key, pro: { active }, you, max_depth, devices: [{ id, device_key, label, prf, approved_by, parent_id, depth, scope, created_at, expires_at, last_used_at, status, read_only, revoked_at, revoked_by, is_you, cert }] }. status: active, revoked, expired, parent_revoked, parent_expired; read_only = active while Pro is lapsed.
  • POST /v1/account/devices/:id/revoke (I: any; a device: itself always, others with admin:devices.revoke and live Pro) → { ok, id, revoked_at, revoked_by, already_revoked, children_inactive }.
  • POST /v1/account/devices/:id/renew (I, or an approving device that is not the target) { delegation } → { ok, device } (same id, new cert; needs Pro).
  • POST /v1/account/session (I or device) → account session JWT: aud txpass-account, sub = I, act.sub = D, dlg = D's cert id, adm = effective admin scopes, at most 900 s and never past the device cert. Introspection answers { active: false, reason: "device_revoked" | "device_expired" } once the device is removed.

Requests signed by a device key on other routes (default: refused with 403 device_action_not_allowed):

RouteActs asNeeds
GET /v1/account/entitlements, GET /v1/account/pro, GET /v1/delegations, GET /v1/policies, /v1/policies/:id, /:id/agents, /:id/eventsIadmin:account.read (receipt reveal/payload only with admin:salts.read)
POST /v1/delegationsD (cert issued by D with parent)admin:agents
POST /v1/delegations/:id/revokeI (finds certs issued by I or any of I's devices)admin:agents
POST /v1/receipts (revoke payload only)Iadmin:receipts.revoke
POST /v1/account/pro/enrol, backup routes, root-holder activate/read—403 root_only

Invites (/pair-device, slice 5). Rate limits: device_invite (10/IP, 5/identity per minute), device_invite_peek (20/IP per minute).

  • POST /v1/devices/invites (I, or a device with admin:devices.approve at depth 1) → 201 { id, invite, url, identity_key, status, created_at, expires_at, interval }. invite is a 128-bit base64url secret, returned once (only its sha256 is stored); url is <issuer origin>/pair-device#i=<invite>. Single use, 10 minutes, 3 open per identity (429 too_many_invites); 402 txpass_pro_required without live Pro.
  • GET /v1/devices/invites/:id (the invite's identity: I or its devices) → { id, identity_key, status, created_at, expires_at, used_at, request? }; status pending / answered / expired / cancelled; request is the answering device request. Others → 404.
  • POST /v1/devices/invites/:id/cancel (same) → { ok, id, status: "cancelled" } or 409 invite_not_pending.
  • POST /v1/devices/invites/peek (no auth) { invite } → { identity_key, kind, expires_at } or 410 invite_invalid. Does not use the invite.
  • POST /v1/devices/requests with { invite, prf: true } answers an invite: needs Origin = the issuer origin (403 invite_origin_mismatch) and prf: true (400 device_prf_required); uses the invite (410 invite_invalid if used, cancelled or expired); the response adds invite_id and identity_key. Such a request can only be looked up, approved or denied by that identity (404 on lookup, 403 request_bound_to_other_identity).
  • GET /pair-device → 302 /account/pair-device (the page; strict CSP, frame-ancestors 'none').

Team members (slice 6, TXPASS_TEAM_MEMBERS, needs TXPASS_DEVICE_DELEGATION). A member is another person's own identity key T granted a role by the root I: team:admin (admin:account.read, admin:agents, admin:receipts.revoke, admin:devices.revoke) or team:viewer (admin:account.read). Rate limits: invites as above; team writes use device_write, reads read.

  • POST /v1/devices/invites (I only; devices and members 403 root_only) { kind: "team", role } → as above plus kind, role; url is <issuer origin>/pair-device#t=<invite>. 400 for an unknown role, 402 txpass_pro_required without Pro. Peek returns kind and role.
  • POST /v1/devices/requests (signed by T) { invite, label } answers a team invite (Origin-bound, single use; no prf needed). 409 team_member_is_owner / team_member_is_device / team_member_exists. Team requests can't be looked up by code.
  • POST /v1/devices/requests/:id/approve (I only) { delegation }: an admin cert from I to T whose scope is exactly the role's (400 team_scope_mismatch), Pro again (402) → 201 { member }.
  • GET /v1/account/team (I; members read) → { identity_key, pro, you, roles, members[] }; each member { id, member_key, role, label, status, read_only, created_at, expires_at, last_used_at, revoked_at, revoked_by, left, is_you }.
  • POST /v1/account/team/:id/revoke (I only) → { ok, id, revoked_at, revoked_by, already_revoked }.
  • GET /v1/account/teams (T's own ID) → { member_key, act_for_header, teams[] }; POST /v1/account/teams/:id/leave (T) → { ok, id, identity_key, revoked_at, already_revoked } (works while Pro is lapsed).
  • Acting for a team: T signs requests as usual and adds the header x-bsv-txpass-act-for: <I> (BRC-103 signs x-bsv-* headers). The request then goes through the device rule table above, with the member's role as its scope; only GET /v1/account/devices, POST /v1/account/devices/:id/revoke, POST /v1/account/session and POST /v1/delegations are allowed among the device-own rules (403 team_action_not_allowed). No live grant → 403 team_membership_not_found; removed / expired → 403 team_member_revoked / team_member_expired. Sessions carry sub = I, act.sub = T. Agents delegated while acting chain to the grant's cert id and pause on Pro lapse like device agents. With the flag off the header is ignored.

A removed or expired device gets 403 device_revoked / device_expired (reason, identity_key, device_id). While Txpass Pro is lapsed on I a device keeps admin:account.read only (403 device_read_only, reason: "pro_lapsed") and can remove itself; agents delegated from a device get 402 txpass_pro_required (reason: "device_suspended") at POST /v1/session, and introspection answers { active: false, reason: "device_suspended" }. With the flag on, GET /v1/delegations (issuer view) also lists agents delegated from I's devices (issued_by: { device_id, device_key, label }), and POST /v1/delegations refuses txpass-account certs (delegation_account_product_reserved; devices are recorded by approval).

Sign in with Txpass: /pair (TXPASS_PAIR_POPUP)

Mounted only when the flag is on (otherwise every route below is 404 and /publisher hides the field). A publisher site opens a popup (or redirects) to Txpass, the user signs in and confirms, and the publisher's backend exchanges a one-time code for the user's identity public key: the authorization-code flow with PKCE (S256, RFC 7636). Guide: Sign in with Txpass.

  • GET /pair?product&return_url&state&code_challenge&code_challenge_method=S256&mode=popup|redirect[&identity_hint][&prompt=sign_in] → 302 /account/pair?… (same query; Cache-Control: no-store, Referrer-Policy: no-referrer). The page runs on /account (strict CSP, frame-ancestors 'none'), with Cross-Origin-Opener-Policy: unsafe-none on this path only so it can postMessage to its opener. state is 22–512 chars of A–Z a–z 0–9 . _ ~ -; code_challenge is 43 base64url chars.
  • POST /v1/pair/check (no auth) { product, return_url } → { product, name, return_origin, return_url, code_ttl_seconds: 60 }. 404 product_not_found; 400 return_origin_not_registered when the origin of return_url is not in the product's return origins; 400 invalid_request for a malformed field or return_url (https://, or http:// on localhost / 127.0.0.1 / [::1]; no credentials or fragment; ≤ 2048 chars).
  • POST /v1/pair/codes (BRC-103, signed by the user) { product, return_url, state, code_challenge, code_challenge_method: "S256", mode } → 201 { code, state, product, return_origin, redirect_to, expires_at, message_type: "txpass:pair" }. code is a 128-bit base64url secret, returned once (only its sha256 is stored), single use, 60 seconds, bound to the product, the return origin and the challenge. redirect_to is return_url with code and state added. Same 404 / 400 as check. A device key may call this as its root identity (rule admin:pair); while Pro is lapsed it gets 403 device_read_only. Team act-for is refused (403 team_action_not_allowed): members link publishers as their own identity only.
  • POST /v1/pair/token (no auth) { code, code_verifier, product, return_url } → { identity_key, product, paired_at } (66-hex key; paired_at in Unix seconds). The code is used up by this call before anything else is checked, so a wrong attempt burns it. Unknown, used or expired code, other product, other return origin or a verifier whose S256 does not match → 400 invalid_grant (one error for all); a malformed body → 400 invalid_request. The response says nothing about how the user signed in (passkey, wallet, device).
  • POST /v1/publisher/products/:slug/return-origins (owner) { return_origins: string[] } → { ok, product: view }; the owner view gains sign_in: { return_origins, source: "publisher" | "seed" | "none" }. Up to 10 bare origins, same scheme rules as return_url, normalised and de-duplicated; [] turns the button off for the product. Writes use delegation_write. Non-owner or unknown → 404 product_not_found; invalid → 400 publisher_invalid.

Env: TXPASS_PAIR_POPUP (1 mounts it; default off); TXPASS_PAIR_RETURN_ORIGINS (optional JSON { "<product>": ["https://app.example.com"] }, validated at boot) seeds return origins for products with no saved list; a saved list, even an empty one, wins. Storage follows DATABASE_URL (tables pair_codes, product_return_origins; migration 0005_pair_popup.sql, applied on start-up only when the flag is on). The startup log prints pairPopup=on (store=memory|postgres[, seeded return origins for <products>]) or pairPopup=off.

GET /v1/account/pro and POST /v1/account/pro/enrol (Txpass Pro, testing phase)

Auth: BRC-103 session identity (or stub); each identity reads and enrols only itself. Txpass Pro is the Txpass account plan (ADR 0003): a Txpass-issued receipt on the reserved product txpass-account, plan pro, whose seller is the Txpass receipt issuer public key (Txpass is its own publisher). It is verified like any other Txpass-issued receipt and listed, with its salt, in GET /v1/account/entitlements.

GET /v1/account/pro → 200 (Cache-Control: private, no-store):

{
  "ok": true, "product": "txpass-account", "plan": "pro",
  "status": "active",
  "valid_from": 1800000000, "valid_until": 1802592000,
  "chain_end": 1805184000, "ended_at": null,
  "enrol_available": true, "period_seconds": 2592000
}

status: active (a receipt covers now), upcoming (only a future one), lapsed (had Pro, none live; ended_at), not_enrolled. chain_end is when Pro lapses counting chained renewals. enrol_available = TXPASS_PRO_FREE_ENROL is on and the receipt issuer is configured. test_period: true appears while TXPASS_PRO_TEST_PERIOD_SECONDS is set.

POST /v1/account/pro/enrol (body {}): 404 not_found unless TXPASS_PRO_FREE_ENROL is on (checked before authentication). Issues one period (30 days, or TXPASS_PRO_TEST_PERIOD_SECONDS) with no payment; valid_from = the current chain end when Pro is active (renewals chain), else now. → 201 { ok, status: "enrolled", chained, receipt_txid, valid_from, valid_until, eval, pro }. Errors: 409 pro_already_ahead (the chain already ends more than one period from now; chain_end), 409 pro_enrol_in_progress, 503 issuer_not_configured / issuer_insufficient_funds / issuer_chain_unavailable, 502 receipt_broadcast_failed, 429 (bucket pro_enrol). It does not renew automatically. The 30-day minimum of Txpass-issued receipts is relaxed only here, for the test period.

GET /v1/account/entitlements (account-wide, read-only)

Auth: BRC-103 session identity (or stub). Query: optional product filter.
Always 200 for an authenticated caller (401 only without an identity), even when the list is empty. Returns every entitlement row of the caller across all products, including expired, revoked and not-yet-valid ones, each with its status computed at at (@txpass/verify evaluateEntitlement, after the same revoke_out watch session minting uses). It never auto-enrolls. For the caller's own receipts that have a stored identity salt (commitment receipts, including Txpass-issued ones) each row also carries reveal: { identityCompressedHex, saltHex } and payload: the proof material the owner needs to show the receipt to any other verifier (with receipt_txid). This route only ever returns the authenticated caller's rows; salts are never returned on public routes, to publishers, or to other identities. usable = the row would back a session right now (live, and verified when TXPASS_IGNORE_UNVERIFIED_RECEIPTS is on). Sorted by valid_until, newest first. Cache-Control: private, no-store. This is the data source for the /account Access screen; the product-gated GET /v1/entitlements below is unchanged.

{
  "identity": "02ab…",
  "at": 1800000000,
  "counts": { "active": 1, "expired": 1, "revoked": 0, "not_yet_valid": 0 },
  "entitlements": [
    {
      "product": "imprintai-api", "plan": "pro-month", "source": "on_chain",
      "receipt_txid": "abc…", "valid_from": 1799136000, "valid_until": 1801728000,
      "revoke_out": "none", "revoked": false,
      "verified_at": 1799136300, "paid_sats": 60000, "block_height": 899990,
      "status": "active", "live": true, "reason": "within validity window and not revoked", "usable": true
    },
    {
      "product": "imprintai-api", "plan": "free", "source": "auto_enroll",
      "receipt_txid": "autoenroll:…", "valid_from": 1480000000, "valid_until": 1795000000,
      "revoke_out": "none", "revoked": false,
      "verified_at": null, "paid_sats": null, "block_height": null,
      "status": "expired", "live": false, "reason": "expired at 1795000000", "usable": false
    }
  ]
}

source is on_chain (receipt ingest, the default for rows without a source), txpass_issued (recorded by Txpass at the publisher's request; paid_sats is 0) or auto_enroll. status is active | expired | revoked | not_yet_valid. 400 invalid_request for an empty or over-long product.

GET /v1/entitlements?product=imprintai-api

Auth: BRC-103 session identity (or stub).
Query: product (default imprintai-api).

200 example (live):

{
  "identity": "02ab…",
  "product": "imprintai-api",
  "entitlement": {
    "identity": "02ab…",
    "product": "imprintai-api",
    "plan": "pro-month",
    "valid_from": 1700000000,
    "valid_until": 1702592000,
    "receipt_txid": "abc…",
    "revoke_out": "none",
    "revoked": false
  },
  "eval": {
    "live": true,
    "status": "active",
    "reason": "within_window"
  }
}

401: no authenticated identity.
402: identity present but no live entitlement (eval.status may be missing | expired | revoked | not_yet_valid).

Publisher rules:

  • Accept only product === "imprintai-api" (allowlist).
  • Accept plans: free (facilitator auto-enroll) | pro-month | pro-year | enterprise-month. Map free → Imprint Free-tier quotas locally.
  • Map locally: pro-* → Premium quotas; enterprise-month → Enterprise quotas.
  • Use identity (or receipt_txid) as quota owner key, not a Clerk user id.
  • If facilitator is unreachable or response fails schema checks → fail closed (401/503), do not grant access.

POST /v1/receipts

Ingest a paid subscribe receipt, or revoke one. A paid entitlement is activated only after the receipt transaction has been verified against the chain. The pure checks live in @txpass/verify (verifyReceiptTx, verifyReceiptInclusion), so any operator running the published verifier with the same /v1/pricing config reaches the same answer. Chain access sits behind a provider interface (apps/facilitator/src/chain-provider.ts; WhatsOnChain by default).

Body (subscribe):

{
  "payload": "<exact OP_RETURN text, canonical txpass/v1/sub form>",
  "receipt_txid": "<64 hex>",
  "reveal": { "identityCompressedHex": "02…", "saltHex": "<64 hex>" },
  "beef": "<optional BEEF (BRC-62/95/96), hex or base64, containing the receipt tx + merkle path>",
  "paidSats": 50000
}
  • beef is recommended. When it is omitted, the facilitator fetches the raw tx and its BUMP merkle path from the provider (TXPASS_CHAIN_PROVIDER, default WhatsOnChain).
  • paidSats is ignored; it is accepted only for compatibility. The facilitator may report paid_sats from outputs for diagnostics; it does not gate on a plan price.
  • allowBare was removed from the body. Bare-identity receipts are a server policy only (TXPASS_ALLOW_BARE_IDENTITY=1), for an operator trying the rail on their own API before anyone else. Clients cannot turn it on.

What is verified (all must pass):

  1. The tx parses and its txid equals receipt_txid. A BEEF must contain that tx.
  2. The tx has exactly one txpass / v1 / … OP_RETURN output (OP_FALSE OP_RETURN <single push>, or legacy OP_RETURN <push>), and it is byte-identical to payload. The payload must also be in canonical form (the fields in order, \n separators, no extra lines).
  3. seller and product match a configured paid product, and plan exists in that product's config. A Txpass-issued receipt (step 5) needs no seller config: its seller is the publisher identity Txpass authorised when issuing it, and its plan follows the issued-receipt plan rules (see Publisher-issued receipts).
  4. The identity commitment equals sha256(I ‖ salt) for the revealed I, and I equals the AuthFetch identity (a bare I only if the server policy allows it).
  5. Either the receipt is Txpass-issued (one of its inputs spends a P2PKH output of an accepted receipt issuer key, see GET /v1/receipts/issuer; the parent tx comes from the BEEF or the chain provider), or at least one output pays the product's configured pay_to locking script(s) (legacy; any positive sat amount). There is no plan-price check. pay_to and the issuer keys come from server config and never from the client.
  6. The window is sane: valid_until > now and valid_until − valid_from ≤ duration_days·86400 + grace_seconds. For legacy pay-to receipts also valid_from ≤ now + skew and valid_until − valid_from ≥ 30·86400. Txpass-issued receipts may start in the future (a chained renewal; the row is not_yet_valid until then) and are accepted when shorter than 30 days: the issuer only issues those for a plan change (see below). Paid plan duration_days in config must be 30..3660. Agent sessions stay short (15–60 min) and mint from that entitlement; free auto-enroll is unchanged.
  7. Inclusion: the merkle path computes to a block merkle root, which is checked against block headers through a ChainTracker (by default one backed by WhatsOnChain headers). TXPASS_RECEIPT_MIN_CONFIRMATIONS defaults to 1. At 0, an unconfirmed tx is accepted only if the BEEF carries ancestry that passes Transaction.verify (SPV).
  8. revoke_out: if the receipt names an outpoint, that outpoint must be unspent (provider lookup). none is always valid.

Responses:

HTTPstatus / errorMeaning
201status: "active"Verified and activated. Body: { ok, status, entitlement, eval, verification: { txid, proof, block_height, confirmations, min_confirmations, paid_sats, payment_vouts, op_return_vout, proof_source, revoke_out, issued_by?, issuer_vin? } }. entitlement.source is txpass_issued (with issued_by) for Txpass-issued receipts, else on_chain.
200status: "already_ingested"Same identity re-posted an already verified txid. Idempotent; a revoked receipt stays revoked.
200status: "verified_existing"A legacy stub-activated row of the same identity is now verified and upgraded in place.
202error: "receipt_pending", status: "pending"The tx was found and its contents are valid, but it doesn't have enough confirmations yet. Not activated; post again later.
400receipt_request_invalid, receipt_payload_invalid, receipt_identity_invalid, receipt_reveal_mismatch, receipt_bare_identity_disabled, receipt_txid_mismatchMalformed request or payload
403identity_mismatchThe receipt identity is not the AuthFetch caller (same code the pre-verification ingest used)
404receipt_tx_not_foundThe provider does not know the tx, and no BEEF was sent
409receipt_already_claimedThis txid is already bound to another identity (first verified identity wins)
422receipt_payload_mismatch, receipt_multiple_payloads, receipt_unknown_product, receipt_seller_mismatch, receipt_unknown_plan, receipt_wrong_payee, receipt_window_invalid, receipt_proof_invalid, receipt_tx_invalid, receipt_revoke_out_invalid, receipt_revoke_out_spentThe receipt does not verify
503paid_not_configuredNo legacy pay-to config and no receipt issuer keys in production. Txpass-issued receipts need no paid config. Free auto-enroll is unaffected.
503receipt_chain_unavailableThe chain provider is down or rate-limited. Retry.

Body (revoke): { "payload": { "type": "revoke", "receipt_txid": "<txid>" } } (or the txpass / v1 / revoke text form). Only the receipt's identity I, an owner of the receipt's product (the same owners as /publisher), or a seller pubkey configured for it, may revoke. Anyone else gets 403 receipt_revoke_forbidden. An unknown txid gets 404 receipt_not_found, and facilitator-issued free rows get 400. Revocation is sticky. A success response is { ok: true, type: "revoke", receipt_txid, revoked_by: "identity" | "owner" | "seller" }.

revoke_out after ingest: when a session is minted or entitlements are loaded, a receipt whose revoke_out is an outpoint is re-checked at most every TXPASS_REVOKE_OUT_RECHECK_SECONDS (default 600). The cache is in memory, per process. If the outpoint has been spent, the receipt is marked revoked. Provider errors fail open and serve the last known state, and the error is logged.

Legacy rows: receipts activated by the old stub (no verified_at) are still honored unless TXPASS_IGNORE_UNVERIFIED_RECEIPTS=1. Audit them with pnpm ops:audit-receipts (see deploy-railway.md).


Publisher-issued receipts

The publisher bills its customer (card, invoice, its existing plans) and then asks Txpass to record the subscription on chain. Txpass builds the txpass / v1 / sub receipt (no prices on it), funds it from the Txpass receipt issuer key, broadcasts it and registers it for the customer's wallet identity. Txpass is not involved in billing, does not meter calls and issues no API keys: the publisher signs each request with its publisher identity key, an owner identity of the product (the key you sign in to /publisher with). Txpass holds only public keys. No per-product seller key, pay-to or TXPASS_PAID_PRODUCTS_JSON entry is needed. Client helpers: @txpass/client issueReceipt / syncReceipt / revokeReceipt / createPublisherClient; walkthrough: Stripe webhook example.

RouteUse it for
POST /v1/receipts/issueNew subscription, renewal (chained), the new plan after a plan change
POST /v1/receipts/syncFirst wallet link: record the customer's current window once (no-op if an equal or later Txpass-issued receipt for the plan exists)
POST /v1/receipts/revokeImmediate cancel, refund, chargeback, the old plan in a plan change
GET /v1/receipts/issuer (public)The issuer public keys verifiers trust

Auth (all three POST routes): an AuthFetch (BRC-103) request from the publisher key, whose JSON body is a signed txpass/v1/publisher-request document (@txpass/protocol createPublisherRequest) signed by the same key: BRC-100 signature over the canonical JSON without sig, protocol [2, "txpass"], key ID v1 publisher, counterparty anyone. Checked in this order: issuer configured (503) → shape and type → signature → aud equals this facilitator's issuer string (GET /v1/protocol → delegation.audience) → |ts − now| ≤ 300 s → signer equals the AuthFetch identity → signer is authorised for product → nonce not seen before → idempotency on (product, event_id).

Authorised signers: an active owner of the product's publisher, i.e. the owners /publisher uses: publisher_owners rows that are not removed, via products.publisher_id, for a product that is live (not in review), plus TXPASS_BOOTSTRAP_OWNERS keys for built-in products. For backward compatibility a seller key configured for the product (TXPASS_PAID_PRODUCTS_JSON / product_access_policy.sellers) is also accepted. The receipt's seller field is the signer.

{
  "v": "txpass/v1/publisher-request",
  "type": "receipt.issue",
  "publisher": "02…(product owner identity key)",
  "aud": "https://txpass.app",
  "product": "imprintai-api",
  "event_id": "evt_1Q2w3E",
  "ts": 1800000000,
  "nonce": "<64 hex, random>",
  "identity": "03…(customer wallet identity key)",
  "plan": "pro-month",
  "valid_from": 1800000000,
  "valid_until": 1802592000,
  "sig": "<DER hex>"
}
  • event_id: your own event id (e.g. the billing webhook event id), 1–200 chars of A–Z a–z 0–9 _ . : - /. Retrying the same event_id with the same content (fresh ts/nonce) returns the stored result with idempotent_replay: true and never issues twice; reusing it for different content is 409 event_id_conflict.
  • Issue / sync: identity, plan (a paid plan slug, not free), valid_from, valid_until (unix seconds). valid_until > now; 30·86400 ≤ valid_until − valid_from ≤ duration_days·86400 + grace_seconds.
  • Plans. Slugs are your own plan names. If the product has a plan list (product_paid_plans, else TXPASS_PAID_PRODUCTS_JSON plans), plan must be on it; with no list, any paid slug ([a-z0-9][a-z0-9-]{0,63}) is accepted. duration_days comes from the configured plan, else the @txpass/protocol plan table (e.g. pro-month 30, pro-year 365), else 366 (one year). grace_seconds comes from the product's config, default 86400. GET /v1/receipts/issuer lists each product's plans ("any" when there is no list). valid_from may be in the future only to chain a renewal: no later than the end of the customer's current unrevoked receipt for the product (+ clock skew).
  • Plan change (issue only): add "reason": "plan_change" and "replaces_receipt_txid": "<the receipt being replaced>" (@txpass/client changePlan does this). The replaced receipt must belong to the same identity and product. Only such an issue may be shorter than 30 days (the rest of the period), and then valid_until may not be later than the replaced receipt's valid_until. If the replaced receipt is still live, Txpass revokes it as part of the issue (replaced: { receipt_txid, revoked, revoked_now, revoke_record? } in the response). A plan change that starts a full new period (≥ 30 days) is not capped.
  • Revoke: exactly one of receipt_txid or target_event_id (the event_id that issued it), optional reason (≤ 200 chars, one line; stored, never on chain). Only receipts of a product you are authorised for (owner or configured seller) can be revoked.

Success:

HTTPstatusBody
201issued{ ok, status, type, product, event_id, receipt_txid, issuer_public_key, chained, replaced?, entitlement, eval } (entitlement.source: "txpass_issued"). The identity salt is never in this response; the customer sees it on GET /v1/account/entitlements.
200already_currentsync only: an equal or later Txpass-issued receipt for the plan exists; nothing was broadcast
200revoked{ …, receipt_txid, revoke_record: { published, txid? }, entitlement, eval }. Txpass also publishes a txpass / v1 / revoke record funded by the issuer key (best effort; the revocation applies immediately either way)
200already_revokedthe receipt was already revoked

Errors:

HTTPerrorMeaning
400publisher_request_invalid, publisher_request_type, publisher_request_audienceMalformed document, wrong route for type, or wrong aud
400receipt_unknown_plan, receipt_window_invalidPlan not configured for the product; window ended, shorter than 30 days without a plan change, a short plan-change window ending after the replaced receipt, too long for the plan, or a future valid_from with nothing to chain after
400receipt_replace_invalidreplaces_receipt_txid is unknown or belongs to another identity or product
401publisher_signature_invalid, publisher_request_staleBad signature; ts more than 300 s from server time
403publisher_mismatch, publisher_not_authorizedDocument signer is not the AuthFetch caller; key is not an active owner of the live product (or a configured seller key)
404receipt_not_foundRevoke target unknown for this product
503publisher_lookup_unavailableProduct ownership could not be read (database down). Retry
409publisher_request_replayedThis nonce was already used: sign a fresh request
409event_id_conflict, event_in_progressevent_id reused for different content; or still being processed (retry shortly)
502receipt_broadcast_failedThe broadcaster rejected the transaction; nothing was recorded, retry the same event_id
503issuer_not_configuredThis facilitator has no receipt issuer key yet
503issuer_insufficient_funds, issuer_chain_unavailableOperator side: issuer wallet empty, or UTXO lookup down. Retry later

GET /v1/receipts/issuer (public, Cache-Control: public, max-age=60): { status, public_key, accepted_public_keys, rule, products }. products lists each live product: { product, ready, has_owners, plans, grace_seconds }, where ready = the issuer key is configured and the product has at least one owner, and plans is the slug list or "any". Owner keys are not listed. GET /v1/pricing carries the same list as receipt_issuance_products. A receipt is Txpass-issued when one of its inputs spends a P2PKH output of an accepted key. Pass accepted_public_keys as issuers to @txpass/verify verifyReceiptTx to verify Txpass-issued receipts without calling Txpass (see Portable verifier).

Operator env: TXPASS_RECEIPT_ISSUER_KEY (secret: WIF or 64-hex private key; never logged, only its public key is printed at startup; unset → 503 issuer_not_configured), TXPASS_RECEIPT_ISSUER_PREVIOUS_PUBLIC_KEYS (comma-separated retired issuer public keys still accepted), TXPASS_RECEIPT_BROADCASTER (arc, default, or woc), TXPASS_ARC_URL (default https://arc.gorillapool.io), TXPASS_ARC_API_KEY, TXPASS_RECEIPT_SAT_PER_KB (miner fee rate, default 100). UTXO lookups use WhatsOnChain (TXPASS_WOC_BASE_URL, TXPASS_WOC_API_KEY). Nothing per product: products and owners come from /publisher, plans from the rules above. TXPASS_PAID_PRODUCTS_JSON is not needed for issuing (legacy pay-to receipts only). Rate-limit bucket publisher_receipts: 120 per IP, 120 per identity per window.

Short-lived session / capability JWT

Imprint must not mint a normal persistent api_keys row from Txpass. Use POST /v1/session → Bearer JWT, store binding fields / token_hash_sha256 / jti in a Txpass credential table (txpass_credentials), never plaintext token at rest if avoidable.

Stub mode may mint a session without a wallet. NON-PRODUCTION. Use it only while you are trying the facilitator yourself, before other callers. Prefer brc103 and real keys for staging and production.

Signing key (env)

EnvRole
TXPASS_SESSION_SIGNING_KEYPreferred dedicated Ed25519 key: PKCS8 PEM, JWK JSON, or 64-hex seed.
TXPASS_SERVER_PRIVATE_KEYIf session key unset: derive deterministic Ed25519 via HKDF (txpass-session-ed25519-v1).
(neither)Ephemeral in-memory Ed25519. Not for production — the key is lost on restart, so use it only while trying the facilitator locally.
TXPASS_SESSION_PREVIOUS_PUBLIC_KEYSOptional, key rotation: extra verification-only public keys published in the JWKS (never used to sign). JSON: a JWKS {"keys":[…]} (e.g. the old GET /v1/session/jwks body verbatim), an array of JWKs, or one JWK. Ed25519 only; a key with private material (d) refuses startup. See runbook-key-rotation.md.
TXPASS_ISSUERJWT iss. Set explicitly: production is https://txpass.app (full URL, no trailing slash), and it must match the publisher's expected iss exactly. Used exactly as given (whitespace trimmed). A trailing / or a value without http(s):// refuses startup. Unset with NODE_ENV=production refuses startup. Unset in dev: https://${RAILWAY_PUBLIC_DOMAIN} if present, else http://localhost:${PORT:-8787}.

Algorithm: EdDSA (Ed25519). JWKS at GET /v1/session/jwks. Never log the full access_token.

kid: every JWT header carries kid = RFC 7638 SHA-256 thumbprint of the signing public key (a JWK-format TXPASS_SESSION_SIGNING_KEY may carry its own kid). Verifiers select the key by kid. The JWKS lists the current signing key first, then any previous keys. Startup logs print sessionKid=… and previousKids=…. pnpm session-key inspect prints the kid/JWKS the current env would publish, and pnpm session-key generate creates a new key.

POST /v1/session (auth required)

Auth: BRC-103 AuthFetch session (req.auth.identityKey) or stub x-stub-identity (stub mode only).

Body:

{
  "product": "imprintai-api",
  "receipt_txid": "<optional: bind a specific entitlement>",
  "ttlSeconds": 900
}

product is required. Default TTL 900s (15 min); max 3600s (1 hour); exp is always ≤ entitlement valid_until.

Behavior:

  1. Resolve identity from auth.
  2. Load live entitlement for identity + product (evaluateEntitlement / store). Optional receipt_txid filter. If not live: apply product on_new_identity — auto_enroll creates/renews free row then continues; reject → 402.
  3. Mint short-lived signed JWT (jose, EdDSA).

Claims (minimum):

ClaimMeaning
isshttps://txpass.app in production — publishers must set TXPASS_ISSUER explicitly to this exact value (no trailing slash).
audproduct slug (e.g. imprintai-api)
subidentity compressed pubkey hex (lowercase)
productproduct slug
planplan slug
receipt_txidentitlement receipt
iat, expunix seconds
jtiunique id
scopeoptional [{ method, pathPattern }, …] — defaults from IMPRINTAI entitled routes

Default scope for imprintai-api: POST /api/imprint, /api/log, /api/metadata, /api/label, /api/c2pa/label, /api/label/finalize; GET /api/status/:jobId, /api/metadata/workflow/:workflowId, /api/metadata/:id.

200 response:

{
  "token_type": "Bearer",
  "access_token": "<jwt>",
  "expires_in": 900,
  "expires_at": 1700000900,
  "identity": "02…",
  "product": "imprintai-api",
  "plan": "pro-month",
  "receipt_txid": "…",
  "token_hash_sha256": "<hex sha256(access_token)>"
}

401 — no identity. 402 — no live entitlement (or TTL exhausted vs valid_until).

Delegated sessions (delegation v1)

Add "delegation": { …cert… } to the body; the AuthFetch identity must be the cert's delegate (agent key A). The session is minted on the issuer's (I's) entitlement; I is never auto-enrolled. TTL = min(ttlSeconds ?? 900, 3600, cert max_session_ttl, cert expires_at − now, entitlement valid_until − now). Claims: sub = I (unchanged), plus act: { sub: A }, dlg: <cert id>, scope (the cert scope if given), quota_share (advisory). Response adds act, dlg, delegation_expires_at. Cert aud must equal this facilitator's issuer exactly (* → 400 delegation_audience_wildcard_not_allowed). Errors: { error: "delegation_*", message }; the issuer having no entitlement → 402 payment_required with code: "issuer_no_entitlement". Product flag allow_delegation (true default / false / paid_only; env TXPASS_ALLOW_DELEGATION). Without delegation, request and response are unchanged. Full contract and error table: specs/delegation-v1.md.

Policy sessions (agent policies v1)

Add "policy": "<policy id>", "issuer": "<I>" to the body instead of delegation (both together → 400 session_request_malformed); the AuthFetch identity is an agent key A admitted to that policy. Checks run in the order of agent-policy-v1 §6: policy found → signature → aud/product → window → not revoked → A admitted (agent_not_admitted / agent_removed / agent_pending_confirmation) → daily cap (429 policy_cap_reached; only successful mints count, a failed later check hands the slot back) → scope → allow_delegation → I's entitlement. TTL = min(ttlSeconds ?? 900, 3600, policy max_session_ttl, policy expires_at − now, entitlement). Claims are the delegated shape — sub = I, act: { sub: A }, dlg = the admission id (one per agent per policy), scope = the policy scope, quota_share if set — plus the additive pol = policy id. Response adds pol, policy_expires_at, sessions_today, sessions_per_day. Publishers need no change: verifySessionJwt already accepts it as a delegated session and ignores pol.

Agent policy routes (auth required)

Standing policies: the human (I) signs one policy, then admits agents by key or with one-time codes; each agent mints its own sessions. Full contract, error table and limits: specs/agent-policy-v1.md. Errors are { error, message }. A caller who is not the policy's issuer gets 404 policy_not_found.

RouteCallerPurpose
POST /v1/policies { policy }IRecord a signed txpass/v1/policy. 201 issuer view; identical re-post → 200; same id, different doc → 409 policy_id_in_use. Allowlisted agents become active admissions. At most 50 live policies (policy_limit_reached).
GET /v1/policies[?product=&include_inactive=1]I{ role: "issuer", issuer, policies: [{ policy, status, created_at, revoked_at?, locked_until?, agents: [{ id, agent, via: allowlist|admit|code|connect, status, admitted_at, confirmed_at?, removed_at?, last_used_at?, sessions_today }], counts: { sessions_today, sessions_30d }, open_codes }], limits }. Revoked/expired only with include_inactive=1.
GET /v1/policies?role=agentA{ role: "agent", agent, policies: [{ issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, status, admission: { id, status } }] } (never other agents' keys). Used by txpass-agent policy list and policy enroll --policy.
GET /v1/policies/:id · GET /v1/policies/:id/agentsIOne issuer view (with open_codes) · { policy_id, agents }.
POST /v1/policies/:id/admissions { admit }IRecord a signed txpass/v1/admit: agent (approve a key; active) → 201 { grant_id, kind: "agent", policy_id, admission }; or code_hash (sha256 of a one-time code, generated client-side) → 201 { grant_id, kind: "code", policy_id, expires_at }. Admit expiry ≤ 24 h; ≤ 20 open codes (code_limit_reached); max_agents (policy_cap_reached).
POST /v1/policies/:id/enroll { code, issuer? }ARedeem a code once. 201 { issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, admission: { id, status } }; status is pending when the policy has require_confirm. Errors code_invalid / code_expired / code_already_used / policy_locked (scope: "source" after 10 failures from the caller's network, "policy" after 100 from everywhere) / policy_cap_reached / agent_already_admitted.
POST /v1/policies/:id/agents/:agent/removeIIdempotent. New mints → 403 agent_removed; introspect → reason: agent_removed.
POST /v1/policies/:id/agents/:agent/confirmIpending → active; otherwise 409 admission_not_pending.
POST /v1/policies/:id/revoke { reason? }IIdempotent; reason ≤ 200 chars. New mints → 403 policy_revoked.
GET /v1/policies/:id/events[?before=&limit=]I{ policy_id, events: [{ at, type, agent?, detail? }] }, newest first, limit ≤ 200. Types: created, agent_admitted, code_created, code_redeemed, code_redeem_failed, policy_locked, agent_confirmed, cap_hit, agent_removed, revoked.

Storage follows DATABASE_URL (Postgres, additive tables created on start-up; memory otherwise); GET /v1/admin/health → policyStore.

Agent connect routes (auth required)

The primary way an agent joins (agent-policy-v1 §16): the agent opens a request, the human approves it on /account/connect?r=<id> with one wallet signature. Errors are { error, message }.

RouteCallerPurpose
POST /v1/connect/requests { product, scopes?, label? }A201 { id, user_code, verification_uri, verification_uri_complete, product, scope, label?, expires_at, interval }. 15-minute lifetime, single use. scopes = [{ method, pathPattern }] or ["METHOD /path"] (≤ 20, all product routes if omitted; outside the product → connect_scope_invalid; the CLI omits it only when the product has no recommended_scopes, and the approval page then warns the owner). label is sanitized (≤ 48 chars, control/bidi characters removed) and is display text only. Bucket connect; ≤ 3 pending per agent key (connect_pending_limit, 429), ≤ 10,000 in total (connect_capacity, 503).
GET /v1/connect/requests/:idAPoll: { id, status: pending|approved|denied|expired, user_code, expires_at, interval } (+ Retry-After: 3 while pending); approved adds membership (the GET /v1/policies?role=agent row).
GET /v1/connect/requests/:idother walletApproval view while pending: { id, status, agent, label?, product, scope, user_code, created_at, expires_at }; once resolved only { id, status } (+ policy_id for the approver). Unknown / purged → 404 connect_request_not_found.
POST /v1/connect/requests/:id/approveI{ policy }: a new signed policy whose agents include A, same product, scope ⊆ requested (one signature creates the policy and admits A); or { policy_id, admit }: an admit for A on I's own live policy of that product (one signature). Runs the same checks as POST /v1/policies / …/admissions. 200 { id, status: "approved", policy_id, admission, result }. Errors: connect_request_expired (410), connect_request_not_pending (409, with status), connect_agent_mismatch, connect_product_mismatch, connect_scope_exceeds_request, connect_self_approval (403), plus the policy/admit errors. A failed approval leaves the request pending. The agent's admission is recorded with via: "connect" (other keys in a new policy's allowlist stay allowlist), and its agent_admitted event carries detail: { via: "connect", admission, connect_request }.
POST /v1/connect/requests/:id/denyany walletNo document signature. 200 { id, status: "denied" }; not pending → 409 / 410.

Storage: table connect_requests (created on start-up; memory without DATABASE_URL), holding only agent key, label, product, scope, user code, status and the approval result; no IP address. Rows are purged an hour after expiry. GET /v1/admin/health → connectStore.

Publisher dashboard routes (auth required)

Early access; everything is free during early access. All routes are BRC-103 authenticated (stub header in stub mode); one save = one wallet-signed request. Errors are { error, message }. Rate-limit buckets: reads use read, writes use delegation_write. Counts are aggregates only: no user, agent or delegate identities are returned.

RouteCallerPurpose
GET /v1/publisher/productsany{ identity, admin, early_access: { free: true }, products: [view] } — products the caller owns. view = { product, name, description, status: live|pending_review, source: code|publisher, api_base_url, contact_email, config_version, created_at, approved_at, owners, routes: [{ method, pathPattern, label, group, access, recommended }], recommended_scopes, labels_complete, stats: { day, entitlements_active, policies_active, agents_active, delegations_active, agent_sessions_today }, staging_origin, staging_routes, session_mints: { count, last_at }, staging_connect_path }. session_mints is a count and a timestamp only (direct, delegated, and agent-policy mints). source: code = a built-in product with no saved copy yet (labels are the Txpass bootstrap stand-in, config_version 0). A stat is null if its query failed. agent_sessions_today counts agent-policy sessions on the current UTC day (direct and one-off delegated mints aren't counted yet).
POST /v1/publisher/products/:slug/staging { origin, routes: [{ method, pathPattern }] }ownerSet the one staging host and which existing routes are in the trial. origin is an exact http/https scheme+host (optional port), no path, query, hash, or trailing slash. A path is 400 publisher_invalid. routes must be a subset of the product's current routes (empty allowed); unknown routes are rejected. Writes product_cors_origins (replaces that product's rows with this one origin) and product_staging_routes. Does not change product_routes, does not publish the product, and does not change free enrolment. 200 { ok, product: view, staging_origin, staging_connect_path }. Non-owner or unknown → 404 product_not_found.
GET /v1/staging/:slugpublicConnect document for an agent (kind: txpass.staging.connect): product, the one staging host, and the ticked routes, plus how to call the existing POST /v1/connect/requests and POST /v1/session. Not the demo connect flow. No identities. 404 staging_not_set until a single host is saved.
POST /v1/publisher/products/:slug/routes { routes: [{ method, pathPattern, label, group, kind }], recommended_scopes: [{ method, pathPattern }], description?, api_origins?, docs_url?, examples?, expected_version? }ownerSave route copy. Every declared route must be present with a label (1–60) and group (1–32) in the P6 charset (letters, digits, spaces and . , ' ( ) - / &; no < >, no URLs, no markdown), kind read|write; recommended_scopes non-empty, no duplicates, each exactly a declared route. The route set can't change here (unknown/missing route → 400). description ≤ 140. api_origins (optional) replaces the product's registered API origins: array of ≤ 10 bare origins, https only (http only for localhost), no path/query/credentials, normalised and de-duplicated; [] clears it (built-ins fall back to their seed); omitted → unchanged. docs_url (optional): https URL, null/"" clears. examples (optional): ≤ 10 { id, method, path, description, body?, notes?, fields? } (id ^[a-z0-9][a-z0-9-]{0,39}$ unique; method GET/POST/PUT/PATCH/DELETE/HEAD; path starts with /, no host/query/spaces; body a JSON object ≤ 4 KiB, not for GET/HEAD; ≤ 32 KiB total); [] clears (built-ins fall back to their seed). expected_version mismatch → 409 version_conflict. First save of a built-in product creates its publisher copy (owners from TXPASS_BOOTSTRAP_OWNERS, status live, config_version 1); from then on /v1/products serves it. 200 { ok, product: view }. Non-owner or unknown → 404 product_not_found; invalid → 400 publisher_invalid.
POST /v1/publisher/products { product, name, description?, api_base_url, contact_email, routes }anyRequest a new product → 201 { ok, product: view, next } with status: pending_review, visible only to the caller (its first owner) and Txpass admins, not live. product = kebab-case slug 3–48 (txpass* reserved; built-in or existing → 409 slug_taken); api_base_url https, no credentials/query/fragment; routes = [{ method, pathPattern }] or a "METHOD /path" per line string (1–50, :param segments). Routes start unlabelled (GET → read, others write); the owner labels them with the route above. ≤ 3 open requests per identity (429 pending_limit). Each request creates its own publisher.
GET /v1/admin/healthadminDetailed diagnostics (store backends, session-key source, delegation / new-identity policy); see . Non-admin → 403 admin_only.
GET /v1/admin/products[?status=pending_review|live]adminAll publisher-store products (view + submitted_by), pending first. Non-admin → 403 admin_only.
POST /v1/admin/products/:slug/approveadminpending_review → live (records approved_by, approved_at). 409 labels_incomplete unless every route has a label and group and ≥ 1 is recommended; 409 not_pending otherwise; 404 unknown.

Env (none set in production today; a malformed value refuses startup):

VarMeaning
TXPASS_BOOTSTRAP_OWNERSJSON { "<product>": ["<66-hex pubkey>", …] }: owner keys of built-in (code catalog) products, e.g. {"imprintai-api":["03…","02…"]}. They see the product in /publisher and can save its copy; they stay owners of the saved copy (in addition to DB owners). Remove an entry to revoke.
TXPASS_ADMIN_KEYSComma/space-separated 66-hex pubkeys of Txpass admins (review list + approve, GET /v1/admin/health). Unset → nobody can approve or read the admin health.
TXPASS_CONFIG_SOURCEdb (default) or env: env makes /v1/products ignore saved copies and approved products (emergency kill switch).
TXPASS_PRODUCT_API_ORIGINSOptional JSON { "<product>": ["https://api.example.com"] }: replaces the built-in api_origins seed for that product (validated at boot). A DB row with non-empty api_origins still wins.

Storage follows DATABASE_URL (Postgres tables publishers, publisher_owners, products, product_routes, additive, created on start-up; memory otherwise). The startup log prints publisher dashboard: … store= configSource= bootstrapOwners=<product>:<n> adminKeys=<n> (counts only).

Staging trial (slice 2): one staging host per product, a checklist of existing routes, a copyable connect document at GET /v1/staging/:slug, and a read-only mint signal on the owner view. The dashboard shows this on /publisher. Production connect and the demo connect link are unchanged. Free enrolment is not edited here.

Onboarding documents (slice 3): GET /v1/products/:slug and GET /v1/adopt/:slug/agent-brief.md are filled from that same staging form. GET /.well-known/txpass.json only points at those two URL patterns.

Not in this version: editing pricing / pay-to / seller, the 24 h delay and emails, mandatory second owner key, self-serve go-live, analytics beyond counts, adding/removing routes of a live product, and session/policy minting for approved publisher-only products (they are listed, but POST /v1/session scopes and policy/connect product checks still use the code catalog).

Delegation routes (auth required)

RouteCallerPurpose
POST /v1/delegations { delegation }issuer or delegateOptional pre-registration (verifies sig + aud). 201 { ok, id, issuer, delegate, product, expires_at, status }
GET /v1/delegationsissuer{ issuer, delegations: [{ id, delegate, product, not_before, expires_at, created_at, last_used_at, status, revoked_at, cert }], revoked_unseen }
GET /v1/delegations?role=delegate[&issuer=<I>]delegate (agent A)Agent signer v1 (additive; no role keeps the row above unchanged). Certs recorded with the caller as delegate, newest first, at most 50: { role: "delegate", delegate, delegations: [{ id, issuer, product, not_before, expires_at, created_at, last_used_at, status, revoked_at, cert }], limit }. Anyone can sign and record a cert naming A, so agents must pin issuer (and re-verify the cert locally). Bad role/issuer → 400 invalid_request. Used by txpass-agent delegation fetch.
POST /v1/delegations/:id/revoke { reason? }issuerIdempotent; keyed by (caller, id); works before first use. New sessions → 403 delegation_revoked; introspect → active: false, reason: delegation_revoked; locally verified JWTs live until exp.

GET /v1/session/jwks (public)

Returns { "keys": [ … ] } for verifying session JWTs (Cache-Control: public, max-age=60). During a key rotation it holds more than one key (current + previous, distinct kids). Publishers must select by kid, and must refetch on an unknown kid (rate-limited) instead of failing until their cache expires. createSessionJwks(url) from @txpass/verify does both (it caches for 10 min and refetches on an unknown kid at most once per 30 s). If you cache the JWKS document yourself, refetch when verifySessionJwt returns code: "unknown_kid".

POST /v1/session/introspect (public, optional)

Body: { "token": "..." }. RFC 7662-ish: { "active": true, ...claims } (including act / dlg / quota_share for delegated sessions) or { "active": false } for invalid/expired tokens. For a delegated session whose delegation was revoked or has expired the answer is { "active": false, "reason": "delegation_revoked", "dlg", "revoked_at" } or { "active": false, "reason": "delegation_expired", "dlg" } (reason added with agent signer v1; additive, so callers that only read active are unaffected). Policy sessions (JWT has pol) are also checked against the policy and the agent's admission: active answers add pol; inactive ones are { "active": false, "reason": "policy_revoked" | "policy_expired" | "agent_removed" | "agent_pending_confirmation", "dlg", "pol" }. Prefer publisher-local verify via JWKS (verifySessionJwt in @txpass/verify); introspect is useful for revocation-before-exp checks if you keep TTLs longer.

How Imprint TxpassCredentialVerifier should verify

  1. Parse Authorization: Bearer <jwt> (or your dedicated Txpass header).
  2. Fetch + cache JWKS from {FACILITATOR}/v1/session/jwks, and refetch (rate-limited) on an unknown kid so key rotation never breaks sessions (createSessionJwks does this).
  3. Verify JWT (jose / @txpass/verify verifySessionJwt): check signature, aud/product === imprintai-api, exp, sub present, plan ∈ { pro-month, pro-year, enterprise-month }.
  4. Optionally re-check live entitlement via GET /v1/entitlements with AuthFetch — not required if JWT is unexpired and signature-valid. For revocation before exp: keep TTL short or call introspect.
  5. Delegated sessions (delegation certs and agent policies) need no extra code: both carry act.sub and dlg; policy sessions add pol (the policy id), which verifiers may log or ignore. Removing an agent or revoking a policy stops new mints at once; a token already issued stays valid until exp (15–60 min) at a local verifier, so call introspect if you need instant cut-off.
  6. Persist only token_hash_sha256 / jti / binding (sub, receipt_txid, plan, exp) in txpass_credentials — never plaintext token at rest if avoidable.
import { createSessionJwks, verifySessionJwt } from '@txpass/verify'

// Once per process: cached; refetches on unknown kid (≤ 1 per 30 s).
const jwks = createSessionJwks(`${FACILITATOR}/v1/session/jwks`)
// (A plain JWKS document also works: `await fetch(url).then((r) => r.json())`,
//  but then refetch it yourself on result.code === 'unknown_kid'.)
const result = await verifySessionJwt(bearer, jwks, {
  audience: 'imprintai-api',
  issuer: process.env.TXPASS_ISSUER, // Set explicitly to https://txpass.app (exact iss, no trailing slash).
  planAllowlist: ['pro-month', 'pro-year', 'enterprise-month'],
})
if (!result.ok) return failClosed()
// map result.claims → RequestPrincipal source: 'txpass'

Confirmation / reorg policy

TopicCurrentStill open
ConfirmationsActivate only with a merkle proof checked against block headers (TXPASS_RECEIPT_MIN_CONFIRMATIONS, default 1). Unconfirmed → 202 receipt_pending, not activated.—
ReorgNot handled after activationRe-check proofs of recent receipts after reorgs
RevokeRevoke payload (identity or seller only); revoke_out unspent check at ingest plus a cached re-check (≤ every 10 min) at session or entitlement loadBackground watcher; a self-referencing revoke outpoint is not expressible yet (spec gap)

Normalize both rails into one internal principal (storage infrastructure only — no Clerk apply):

type RequestPrincipal = {
  source: 'api_key' | 'txpass'
  ownerId: string          // stable internal id
  publisherId: string      // Imprint publisher id for provenance
  credentialId: string     // apiKeyId OR txpass credential id — never confuse the two
  tier: 'premium' | 'enterprise'
  quotaKey: string         // api key id OR txpass identity / receipt
  txpass?: {
    identity: string
    receiptTxid: string
    plan: 'pro-month' | 'pro-year' | 'enterprise-month'
  }
}
  • Quota-consuming writes: imprint / log / metadata / label
  • Ownership without re-quota: status / finalize / c2pa/label / metadata GETs — keep accessible after entitlement expiry if accepted while live
  • Public routes: unchanged

Security checklist for Imprint

  • ☐ Fail closed if facilitator down / bad schema
  • ☐ Local plan allowlist only
  • ☐ Never accept client-supplied tier
  • ☐ Txpass credentials ≠ ordinary API keys
  • ☐ Skip apply wall for source === 'txpass'
  • ☐ Key path unchanged