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
| Mode | When | Client proves identity how |
|---|---|---|
| brc103 | TXPASS_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). |
| stub | Only while an operator tries the facilitator against their own API before anyone else — not production | Header 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.appexplicitly on the facilitator and publisher (full URL, no trailing slash). It must match the publisher's expected JWTissexactly.
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:
- 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 anyAccess-Control-Request-Headers), andExpose-Headersfor thex-bsv-auth*family. Missing Origin → 204 without ACAO. Present but not allowlisted → 204 without ACAO (auth never runs). Origin must match the allowlist exactly. - Non-OPTIONS (
POST /.well-known/auth,POST /v1/session, …) — same allowlisted Origin getsAllow-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:
| Mode | Behavior |
|---|---|
reject (safe default for unknown products) | 402 payment_required — message mentions subscribe or that auto-enroll is disabled |
auto_enroll | Facilitator 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 }, longduration_days(~10y scaffold)
Env overrides (change quotas or new-identity policy without redeploying code):
| Env | Role |
|---|---|
TXPASS_ON_NEW_IDENTITY | reject | auto_enroll |
TXPASS_AUTO_ENROLL_PLAN | default free |
TXPASS_FREE_QUOTA_PER_DAY / TXPASS_FREE_QUOTA_PER_MONTH | publisher free quotas |
TXPASS_PRODUCT_CONFIG | optional 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).
| Bucket | Routes | Per IP (env …_PER_IP) | Per identity (env …_PER_IDENTITY) |
|---|---|---|---|
handshake | POST /.well-known/auth | 120 | — |
public | GET /health, GET /v1/protocol, GET /v1/pricing, GET /v1/receipts/issuer, GET /v1/products, GET /v1/session/jwks, GET /account* | 300 | — |
introspect | POST /v1/session/introspect | 120 | — |
session | POST /v1/session (incl. delegated) | 30 | 20 |
receipts | POST /v1/receipts | 20 | 10 |
publisher_receipts | POST /v1/receipts/issue, /sync, /revoke (publisher-signed) | 120 | 120 |
passkey_backup | POST /v1/account/backup, POST /v1/account/backup/delete (only with TXPASS_PASSKEY_BACKUP) | 10 | 5 |
passkey_restore | POST /v1/passkeys/backup/restore (unauthenticated; only with TXPASS_PASSKEY_BACKUP) | 10 | — |
root_holder | POST /v1/account/root-holder/activate (only when root-holder gate is on) | 10 | 5 |
device_request | POST /v1/devices/requests, POST /v1/devices/requests/lookup (only with TXPASS_DEVICE_DELEGATION) | 10 | 5 |
device_write | device approve / deny / revoke / renew, POST /v1/account/session, invite cancel | 20 | 10 |
device_invite | POST /v1/devices/invites (/pair-device) | 10 | 5 |
device_invite_peek | POST /v1/devices/invites/peek (unauthenticated) | 20 | — |
pair_check | POST /v1/pair/check (unauthenticated; only with TXPASS_PAIR_POPUP) | 60 | — |
pair_code | POST /v1/pair/codes (signed by the user) | 20 | 10 |
pair_token | POST /v1/pair/token (unauthenticated; publisher backends) | 60 | — |
pro_enrol | POST /v1/account/pro/enrol (free Pro enrol, testing phase; plus one enrolment at a time per identity) | 6 | 3 |
delegation_write | POST /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) | 20 | 10 |
enroll | POST /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) | 10 | 10 |
connect | POST /v1/connect/requests (plus at most 3 pending requests per agent key and 10,000 in total) | 10 | 5 |
read | GET /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) | 60 | 30 |
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:
| Cap | Default | Env |
|---|---|---|
Demo enrols (no policy / delegation) per client IP | 5 per hour | TXPASS_DEMO_ENROLL_PER_IP |
Demo session mints (policy or delegation) per client IP | 5 per hour | TXPASS_DEMO_SESSION_PER_IP |
| Marked sessions per demo identity | 1, no refill until restart | TXPASS_DEMO_SESSIONS_PER_IDENTITY |
| IP window | 3600 s | TXPASS_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…"]
}
payToLockingScriptsHexmay be empty: publisher-issued receipts carry no payment output.receipt_issuanceisconfiguredornot_configured(no issuer key on this facilitator).receipt_issuerslists the accepted Txpass receipt issuer public keys (current first, then retired ones), the same list asGET /v1/receipts/issuer.
status is one of:
configureddev_defaults(non-production placeholders)not_configured/invalid(production: paid ingest is closed with 503). The response then also carries a human-readablenoteandproducts: {}; 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.lookupis 32 bytes base64url (HKDF(same PRF, info "txpass/v1/backup-lookup")); onlysha256(lookup)is stored. The blob must name the signing identity andcredential_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 }or404 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 rootI){ holder_id, source: "create"|"restore"|"recovery_file" }→{ ok, identity_key, holder_id, source, activated_at, previous_holder_id? }.holder_idis base64url, 16–64 bytes. Sameholder_idis 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 rootI+ live holder proof) → live metadata, or403 root_holder_superseded, or404 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 alsoidentity_key,device,delegation(the cert). Others → 404.POST /v1/devices/requests/lookup(I, or a device withadmin:devices.approveat depth 1){ user_code }→{ id, device_key, label, prf, expires_at, identity_key, max_scope, approver };402 txpass_pro_requiredwithout 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, then402 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 withadmin:devices.revokeand 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:audtxpass-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):
| Route | Acts as | Needs |
|---|---|---|
GET /v1/account/entitlements, GET /v1/account/pro, GET /v1/delegations, GET /v1/policies, /v1/policies/:id, /:id/agents, /:id/events | I | admin:account.read (receipt reveal/payload only with admin:salts.read) |
POST /v1/delegations | D (cert issued by D with parent) | admin:agents |
POST /v1/delegations/:id/revoke | I (finds certs issued by I or any of I's devices) | admin:agents |
POST /v1/receipts (revoke payload only) | I | admin: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 withadmin:devices.approveat depth 1) →201 { id, invite, url, identity_key, status, created_at, expires_at, interval }.inviteis a 128-bit base64url secret, returned once (only its sha256 is stored);urlis<issuer origin>/pair-device#i=<invite>. Single use, 10 minutes, 3 open per identity (429 too_many_invites);402 txpass_pro_requiredwithout 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? };statuspending/answered/expired/cancelled;requestis the answering device request. Others → 404.POST /v1/devices/invites/:id/cancel(same) →{ ok, id, status: "cancelled" }or409 invite_not_pending.POST /v1/devices/invites/peek(no auth){ invite }→{ identity_key, kind, expires_at }or410 invite_invalid. Does not use the invite.POST /v1/devices/requestswith{ invite, prf: true }answers an invite: needsOrigin= the issuer origin (403 invite_origin_mismatch) andprf: true(400 device_prf_required); uses the invite (410 invite_invalidif used, cancelled or expired); the response addsinvite_idandidentity_key. Such a request can only be looked up, approved or denied by that identity (404on 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 members403 root_only){ kind: "team", role }→ as above pluskind,role;urlis<issuer origin>/pair-device#t=<invite>.400for an unknown role,402 txpass_pro_requiredwithout Pro. Peek returnskindandrole.POST /v1/devices/requests(signed by T){ invite, label }answers a team invite (Origin-bound, single use; noprfneeded).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 signsx-bsv-*headers). The request then goes through the device rule table above, with the member's role as its scope; onlyGET /v1/account/devices,POST /v1/account/devices/:id/revoke,POST /v1/account/sessionandPOST /v1/delegationsare 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 carrysub= 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'), withCross-Origin-Opener-Policy: unsafe-noneon this path only so it canpostMessageto its opener.stateis 22–512 chars ofA–Z a–z 0–9 . _ ~ -;code_challengeis 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_registeredwhen the origin ofreturn_urlis not in the product's return origins;400 invalid_requestfor a malformed field orreturn_url(https://, orhttp://onlocalhost/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" }.codeis 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_toisreturn_urlwithcodeandstateadded. Same404/400as check. A device key may call this as its root identity (ruleadmin:pair); while Pro is lapsed it gets403 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_atin 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 gainssign_in: { return_origins, source: "publisher" | "seed" | "none" }. Up to 10 bare origins, same scheme rules asreturn_url, normalised and de-duplicated;[]turns the button off for the product. Writes usedelegation_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. Mapfree→ Imprint Free-tier quotas locally. - Map locally:
pro-*→ Premium quotas;enterprise-month→ Enterprise quotas. - Use
identity(orreceipt_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
}
beefis recommended. When it is omitted, the facilitator fetches the raw tx and its BUMP merkle path from the provider (TXPASS_CHAIN_PROVIDER, default WhatsOnChain).paidSatsis ignored; it is accepted only for compatibility. The facilitator may reportpaid_satsfrom outputs for diagnostics; it does not gate on a plan price.allowBarewas 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):
- The tx parses and its txid equals
receipt_txid. A BEEF must contain that tx. - The tx has exactly one
txpass / v1 / …OP_RETURN output (OP_FALSE OP_RETURN <single push>, or legacyOP_RETURN <push>), and it is byte-identical topayload. The payload must also be in canonical form (the fields in order,\nseparators, no extra lines). sellerandproductmatch a configured paid product, andplanexists in that product's config. A Txpass-issued receipt (step 5) needs no seller config: itsselleris the publisher identity Txpass authorised when issuing it, and its plan follows the issued-receipt plan rules (see Publisher-issued receipts).- The identity commitment equals
sha256(I ‖ salt)for the revealedI, andIequals the AuthFetch identity (a bareIonly if the server policy allows it). - 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 configuredpay_tolocking script(s) (legacy; any positive sat amount). There is no plan-price check.pay_toand the issuer keys come from server config and never from the client. - The window is sane:
valid_until > nowandvalid_until − valid_from ≤ duration_days·86400 + grace_seconds. For legacy pay-to receipts alsovalid_from ≤ now + skewandvalid_until − valid_from ≥ 30·86400. Txpass-issued receipts may start in the future (a chained renewal; the row isnot_yet_validuntil then) and are accepted when shorter than 30 days: the issuer only issues those for a plan change (see below). Paid planduration_daysin config must be 30..3660. Agent sessions stay short (15–60 min) and mint from that entitlement; free auto-enroll is unchanged. - 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_CONFIRMATIONSdefaults to1. At0, an unconfirmed tx is accepted only if the BEEF carries ancestry that passesTransaction.verify(SPV). revoke_out: if the receipt names an outpoint, that outpoint must be unspent (provider lookup).noneis always valid.
Responses:
| HTTP | status / error | Meaning |
|---|---|---|
| 201 | status: "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. |
| 200 | status: "already_ingested" | Same identity re-posted an already verified txid. Idempotent; a revoked receipt stays revoked. |
| 200 | status: "verified_existing" | A legacy stub-activated row of the same identity is now verified and upgraded in place. |
| 202 | error: "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. |
| 400 | receipt_request_invalid, receipt_payload_invalid, receipt_identity_invalid, receipt_reveal_mismatch, receipt_bare_identity_disabled, receipt_txid_mismatch | Malformed request or payload |
| 403 | identity_mismatch | The receipt identity is not the AuthFetch caller (same code the pre-verification ingest used) |
| 404 | receipt_tx_not_found | The provider does not know the tx, and no BEEF was sent |
| 409 | receipt_already_claimed | This txid is already bound to another identity (first verified identity wins) |
| 422 | receipt_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_spent | The receipt does not verify |
| 503 | paid_not_configured | No legacy pay-to config and no receipt issuer keys in production. Txpass-issued receipts need no paid config. Free auto-enroll is unaffected. |
| 503 | receipt_chain_unavailable | The 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.
| Route | Use it for |
|---|---|
POST /v1/receipts/issue | New subscription, renewal (chained), the new plan after a plan change |
POST /v1/receipts/sync | First 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/revoke | Immediate 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 ofA–Z a–z 0–9 _ . : - /. Retrying the sameevent_idwith the same content (freshts/nonce) returns the stored result withidempotent_replay: trueand never issues twice; reusing it for different content is409 event_id_conflict.- Issue / sync:
identity,plan(a paid plan slug, notfree),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, elseTXPASS_PAID_PRODUCTS_JSONplans),planmust be on it; with no list, any paid slug ([a-z0-9][a-z0-9-]{0,63}) is accepted.duration_dayscomes from the configured plan, else the@txpass/protocolplan table (e.g.pro-month30,pro-year365), else 366 (one year).grace_secondscomes from the product's config, default 86400.GET /v1/receipts/issuerlists each product's plans ("any"when there is no list).valid_frommay 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/clientchangePlandoes this). The replaced receipt must belong to the sameidentityandproduct. Only such an issue may be shorter than 30 days (the rest of the period), and thenvalid_untilmay not be later than the replaced receipt'svalid_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_txidortarget_event_id(theevent_idthat issued it), optionalreason(≤ 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:
| HTTP | status | Body |
|---|---|---|
| 201 | issued | { 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. |
| 200 | already_current | sync only: an equal or later Txpass-issued receipt for the plan exists; nothing was broadcast |
| 200 | revoked | { …, 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) |
| 200 | already_revoked | the receipt was already revoked |
Errors:
| HTTP | error | Meaning |
|---|---|---|
| 400 | publisher_request_invalid, publisher_request_type, publisher_request_audience | Malformed document, wrong route for type, or wrong aud |
| 400 | receipt_unknown_plan, receipt_window_invalid | Plan 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 |
| 400 | receipt_replace_invalid | replaces_receipt_txid is unknown or belongs to another identity or product |
| 401 | publisher_signature_invalid, publisher_request_stale | Bad signature; ts more than 300 s from server time |
| 403 | publisher_mismatch, publisher_not_authorized | Document signer is not the AuthFetch caller; key is not an active owner of the live product (or a configured seller key) |
| 404 | receipt_not_found | Revoke target unknown for this product |
| 503 | publisher_lookup_unavailable | Product ownership could not be read (database down). Retry |
| 409 | publisher_request_replayed | This nonce was already used: sign a fresh request |
| 409 | event_id_conflict, event_in_progress | event_id reused for different content; or still being processed (retry shortly) |
| 502 | receipt_broadcast_failed | The broadcaster rejected the transaction; nothing was recorded, retry the same event_id |
| 503 | issuer_not_configured | This facilitator has no receipt issuer key yet |
| 503 | issuer_insufficient_funds, issuer_chain_unavailable | Operator 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)
| Env | Role |
|---|---|
TXPASS_SESSION_SIGNING_KEY | Preferred dedicated Ed25519 key: PKCS8 PEM, JWK JSON, or 64-hex seed. |
TXPASS_SERVER_PRIVATE_KEY | If 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_KEYS | Optional, 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_ISSUER | JWT 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:
- Resolve identity from auth.
- Load live entitlement for identity + product (
evaluateEntitlement/ store). Optionalreceipt_txidfilter. If not live: apply producton_new_identity—auto_enrollcreates/renews free row then continues;reject→ 402. - Mint short-lived signed JWT (jose, EdDSA).
Claims (minimum):
| Claim | Meaning |
|---|---|
iss | https://txpass.app in production — publishers must set TXPASS_ISSUER explicitly to this exact value (no trailing slash). |
aud | product slug (e.g. imprintai-api) |
sub | identity compressed pubkey hex (lowercase) |
product | product slug |
plan | plan slug |
receipt_txid | entitlement receipt |
iat, exp | unix seconds |
jti | unique id |
scope | optional [{ 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.
| Route | Caller | Purpose |
|---|---|---|
POST /v1/policies { policy } | I | Record 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=agent | A | { 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/agents | I | One issuer view (with open_codes) · { policy_id, agents }. |
POST /v1/policies/:id/admissions { admit } | I | Record 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? } | A | Redeem 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/remove | I | Idempotent. New mints → 403 agent_removed; introspect → reason: agent_removed. |
POST /v1/policies/:id/agents/:agent/confirm | I | pending → active; otherwise 409 admission_not_pending. |
POST /v1/policies/:id/revoke { reason? } | I | Idempotent; 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 }.
| Route | Caller | Purpose |
|---|---|---|
POST /v1/connect/requests { product, scopes?, label? } | A | 201 { 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/:id | A | Poll: { 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/:id | other wallet | Approval 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/approve | I | { 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/deny | any wallet | No 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.
Env (none set in production today; a malformed value refuses startup):
| Var | Meaning |
|---|---|
TXPASS_BOOTSTRAP_OWNERS | JSON { "<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_KEYS | Comma/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_SOURCE | db (default) or env: env makes /v1/products ignore saved copies and approved products (emergency kill switch). |
TXPASS_PRODUCT_API_ORIGINS | Optional 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)
| Route | Caller | Purpose |
|---|---|---|
POST /v1/delegations { delegation } | issuer or delegate | Optional pre-registration (verifies sig + aud). 201 { ok, id, issuer, delegate, product, expires_at, status } |
GET /v1/delegations | issuer | { 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? } | issuer | Idempotent; 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
- Parse
Authorization: Bearer <jwt>(or your dedicated Txpass header). - Fetch + cache JWKS from
{FACILITATOR}/v1/session/jwks, and refetch (rate-limited) on an unknownkidso key rotation never breaks sessions (createSessionJwksdoes this). - Verify JWT (
jose/@txpass/verifyverifySessionJwt): check signature,aud/product===imprintai-api,exp,subpresent,plan∈{ pro-month, pro-year, enterprise-month }. - Optionally re-check live entitlement via
GET /v1/entitlementswith AuthFetch — not required if JWT is unexpired and signature-valid. For revocation beforeexp: keep TTL short or call introspect. - Delegated sessions (delegation certs and agent policies) need no extra code: both carry
act.subanddlg; policy sessions addpol(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 untilexp(15–60 min) at a local verifier, so call introspect if you need instant cut-off. - Persist only
token_hash_sha256/jti/ binding (sub,receipt_txid,plan,exp) intxpass_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
| Topic | Current | Still open |
|---|---|---|
| Confirmations | Activate only with a merkle proof checked against block headers (TXPASS_RECEIPT_MIN_CONFIRMATIONS, default 1). Unconfirmed → 202 receipt_pending, not activated. | — |
| Reorg | Not handled after activation | Re-check proofs of recent receipts after reorgs |
| Revoke | Revoke payload (identity or seller only); revoke_out unspent check at ingest plus a cached re-check (≤ every 10 min) at session or entitlement load | Background watcher; a self-referencing revoke outpoint is not expressible yet (spec gap) |
Recommended Imprint principal (adopt this)
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