{"openapi":"3.1.0","info":{"title":"Relaystation","version":"v1","description":"Pay-per-call API platform for AI agents and developers. Baton — a prepaid storage object configured along five groups (write behavior · capacity · sharing · lifecycle · trust); five named preconfigs (Drop / Pass / Scratchpad / Checkpoint / Ledger) are fast-start prefills, not the organizing principle. Engine-priced everywhere — one quote, frozen at create.","contact":{"url":"https://relaystation.ai"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://api.relaystation.ai","description":"production"}],"tags":[{"name":"baton","description":"Baton — the prepaid storage object: create, read, write, share, extend"},{"name":"baton-pricing","description":"Public preconfigs + price-of-record"},{"name":"baton-trust","description":"Baton trust services — document-witness, verify-chain, challenge, proof"},{"name":"cputools","description":"cputools — stateless pay-per-call CPU utilities. PDF tool (merge, split, rotate, pages, extract-text, metadata, watermark, form) billed per page; plus the shared storage-ref upload-url. Inline base64 ≤ 4 MiB or a storage-ref { inputKey } from upload-url."},{"name":"bridge","description":"Courier — operator/agent communication bridge (Telegram, email, SMS). Channel-agnostic API: ask (blocks for reply), notify (fire-and-forget), active toggle, cross-product cascade dispatch."},{"name":"account","description":"Customer account + product enrollment"},{"name":"auth","description":"Wallet-JWT challenge/verify"},{"name":"webhooks","description":"Outbound webhook registrations"},{"name":"discovery","description":"OpenAPI / llms.txt / MCP advertisement / system info"}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"rs_live_<32-hex> | rs_test_<32-hex>","description":"API key minted at app.relaystation.ai after OAuth signup."},"walletJwt":{"type":"http","scheme":"bearer","bearerFormat":"JWT (HS256)","description":"Issued by POST /v1/auth/verify after EIP-191 wallet signature. Used for read paths."},"x402":{"type":"apiKey","in":"header","name":"PAYMENT-SIGNATURE","description":"Base64-encoded EIP-3009 PaymentPayload (USDC or EURC, v2, on Base / Base Sepolia). The 402 challenge advertises one accepts[] entry per accepted asset; sign the chosen one. One call = one payment."},"adminSession":{"type":"apiKey","in":"cookie","name":"rs_admin_session","description":"Operator-only admin session cookie (issued by admin Google OAuth, gated by the admin_users allowlist). Used by the operator-facing admin routes — NOT a customer auth mode. Documented here only so the Courier admin refund operation is discoverable; customers cannot call it."}},"schemas":{"WebhookEventName":{"type":"string","enum":["baton.written","token.used","token.exhausted","pass.all_tokens_consumed","bridge.ask.replied"],"description":"A webhook event type from the live allowlist (GET /v1/webhooks/events)."},"V1Webhook":{"type":"object","required":["id","url","events","active","createdAt"],"properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventName"}},"description":{"type":"string","nullable":true},"active":{"type":"boolean"},"consecutiveFailures":{"type":"integer"},"secret":{"type":"string","description":"Returned ONCE on POST /v1/webhooks. Subsequent reads omit."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"V1WebhookDelivery":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"webhookId":{"type":"string","format":"uuid"},"event":{"type":"string"},"subjectId":{"type":"string","format":"uuid","nullable":true},"status":{"type":"string","enum":["PENDING","SUCCESS","FAILED"]},"attempts":{"type":"integer"},"responseCode":{"type":"integer","nullable":true},"errorMessage":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"Error":{"type":"object","required":["error","code"],"properties":{"error":{"type":"string"},"code":{"type":"string"}}}},"responses":{"Unauthorized":{"description":"Missing or invalid auth.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found or not owned by the customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ValidationError":{"description":"Invalid request body or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"paths":{"/health":{"get":{"tags":["discovery"],"summary":"Health","description":"Health check.","security":[],"responses":{"200":{"description":"OK"}}}},"/openapi.json":{"get":{"tags":["discovery"],"summary":"OpenAPI spec","description":"This OpenAPI 3.1 spec.","security":[],"responses":{"200":{"description":"OpenAPI document."}}}},"/llms.txt":{"get":{"tags":["discovery"],"summary":"llms.txt","description":"Plain-text capability summary for LLM agent discovery.","security":[],"responses":{"200":{"description":"Capability summary.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/llms-full.txt":{"get":{"tags":["discovery"],"summary":"llms-full.txt","description":"Long-form plain-text spec — every endpoint, the five-group baton model + preconfigs, the x402 wire, the trust model.","security":[],"responses":{"200":{"description":"Full agent reference.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/.well-known/mcp.json":{"get":{"tags":["discovery"],"summary":"MCP advertisement","description":"MCP-server advertisement.","security":[],"responses":{"200":{"description":"MCP advertisement."},"404":{"description":"MCP disabled (`chassis.feature.mcp_enabled = false`)."}}}},"/mcp":{"post":{"tags":["discovery"],"summary":"MCP transport","description":"JSON-RPC over HTTP. Streamable-HTTP MCP server. Methods: `initialize`, `tools/list`, `tools/call`. Auth flows through the same modes as /v1/*: API key, x402, wallet JWT. Brief 150 — URL-parameter fallback: `?key=rs_live_<key>` is accepted when no `Authorization` header is present (for clients like Cowork that only accept a server URL with no Bearer-token field). Bearer header takes precedence; a Bearer-present-but-invalid request is rejected without consulting `?key=`.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"JSON-RPC result."},"503":{"description":"MCP disabled."}}}},"/v1/auth/challenge":{"get":{"tags":["auth"],"summary":"Wallet challenge","description":"Issue a wallet-sign-in nonce + EIP-191 message.","security":[],"parameters":[{"name":"wallet","in":"query","required":true,"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$"}}],"responses":{"200":{"description":"Challenge."}}}},"/v1/auth/verify":{"post":{"tags":["auth"],"summary":"Verify signature","description":"Verify EIP-191 signature and mint a wallet JWT.","security":[],"responses":{"200":{"description":"JWT minted."}}}},"/v1/webhooks":{"post":{"tags":["webhooks"],"summary":"Register","description":"Register a webhook (returns secret ONCE).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"201":{"description":"Webhook registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Webhook"}}}}}},"get":{"tags":["webhooks"],"summary":"List","description":"List webhook registrations.","security":[{"apiKey":[]},{"walletJwt":[]}],"responses":{"200":{"description":"Webhook list."}}}},"/v1/webhooks/events":{"get":{"tags":["webhooks"],"summary":"Event registry","description":"The live event-type allowlist a webhook can subscribe to (the same set returned in a 400 INVALID_EVENTS response). PUBLIC — discovery, no auth, no per-account variation.","security":[],"responses":{"200":{"description":"Event registry — `{ events: string[] }`.","content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventName"}}}}}}}}}},"/v1/webhooks/{id}":{"get":{"tags":["webhooks"],"summary":"Get","description":"Webhook detail (no secret).","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Webhook."}}},"put":{"tags":["webhooks"],"summary":"Update","description":"Update webhook (active / events / url / description).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated."}}},"delete":{"tags":["webhooks"],"summary":"Delete","description":"Delete webhook (deliveries kept for audit).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."}}}},"/v1/webhooks/{id}/deliveries":{"get":{"tags":["webhooks"],"summary":"Delivery history","description":"Paginated delivery history.","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"status","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery page."}}}},"/v1/account":{"get":{"tags":["account"],"summary":"Get account","description":"Customer profile + balance.","security":[{"apiKey":[]},{"walletJwt":[]}],"responses":{"200":{"description":"Account info."}}}},"/v1/account/products/{product}/enable":{"post":{"tags":["account"],"summary":"Enable product","description":"Enable a Relaystation-family product for the calling customer. Dashboard-only action (rs_session cookie OR wallet-JWT). Rejects API-key + x402. Idempotent — repeated enables return the same shape. No product slugs are registered yet (KNOWN_PRODUCTS is empty); the endpoint stays as a chassis primitive ready for the first V3-native product.","security":[{"walletJwt":[]}],"parameters":[{"name":"product","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Product enabled."}}}},"/v1/account/products":{"get":{"tags":["account"],"summary":"List enrollments","description":"Enrollment list for the calling customer. Returns the products this customer is enrolled in. Accepts API-key, rs_session, or wallet-JWT. Rejects x402 with X402_NOT_APPLICABLE (lodestone callers per D43 never enroll).","security":[{"apiKey":[]},{"walletJwt":[]}],"responses":{"200":{"description":"Enrollment list (may be empty)."},"400":{"description":"X402_NOT_APPLICABLE."},"401":{"description":"MISSING_AUTH."}}}},"/v1/baton/system/info":{"get":{"tags":["discovery"],"summary":"System info","description":"Agent-facing system descriptor (the five-group model, preconfigs, payment modes, merchant wallet, endpoint roster).","security":[],"responses":{"200":{"description":"System info.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/v1/baton/system/egress-ips":{"get":{"tags":["discovery"],"summary":"Egress IPs","description":"Outbound webhook delivery egress IPs (currently dynamic; HMAC-verify advisory in body).","security":[],"responses":{"200":{"description":"Egress IP advisory.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/v1/baton/prices":{"get":{"tags":["baton-pricing"],"summary":"Preconfigs","description":"The named preconfigs and their default shapes (resource tuple + headlineCapacity framing) — fast-start prefills, not priced SKUs.","security":[],"responses":{"200":{"description":"Preconfig list."}}}},"/v1/baton/quote":{"post":{"tags":["baton-pricing"],"summary":"Quote","description":"Public price-of-record for any baton shape (a preconfig ref or a custom shape + optional flags). Engine-quoted; never charges.","security":[],"responses":{"200":{"description":"Quote."},"404":{"description":"Preconfig not found."},"422":{"description":"Flag dependency unmet."}}}},"/v1/baton":{"post":{"tags":["baton"],"summary":"Create","description":"Create a baton. Billable — the engine quote of the shape (a preconfig ref or a custom shape), floored at the network minimum.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"201":{"description":"Baton created."},"402":{"description":"Payment required (x402 challenge)."}}}},"/v1/baton/{id}":{"get":{"tags":["baton"],"summary":"Read","description":"Read a baton (owner-addressed by UUID, OR token-addressed by `tok_<43-char>`).","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Baton."},"410":{"description":"Content gone."}}},"post":{"tags":["baton"],"summary":"Write","description":"Write/append (owner-addressed by UUID, OR token-addressed). Append on append / append-chained batons; overwrite on single-object / overwrite batons.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Entry appended."},"409":{"description":"Quota exhausted / baton frozen."}}},"delete":{"tags":["baton"],"summary":"Delete","description":"Soft-delete; sweeper purges per on_expiration.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Delete scheduled."}}}},"/v1/baton/{id}/entries":{"get":{"tags":["baton"],"summary":"List entries","description":"Paginated entries (owner or token-addressed).","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"since","in":"query","schema":{"type":"string"}},{"name":"until","in":"query","schema":{"type":"string"}},{"name":"writer","in":"query","schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Entries page."}}}},"/v1/baton/{id}/tail":{"get":{"tags":["baton"],"summary":"Tail","description":"Long-poll for new entries (owner or token-addressed).","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"New entries since `?since=`."}}}},"/v1/baton/{id}/add":{"post":{"tags":["baton"],"summary":"Add resources","description":"Buy more of the same preconfig (another tier of resources). Billable.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Additive resources applied."}}}},"/v1/baton/{id}/extend":{"post":{"tags":["baton"],"summary":"Extend","description":"Buy à-la-carte dimensions (size / duration / egress / writes). Billable.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Dimensions extended."}}}},"/v1/baton/{id}/meta":{"get":{"tags":["baton"],"summary":"Read metadata","description":"Read metadata + flags snapshot.","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Metadata."}}},"post":{"tags":["baton"],"summary":"Update metadata","description":"Mutate metadata (allowlisted fields only).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Metadata updated."},"422":{"description":"INVALID_FIELD."}}}},"/v1/baton/{id}/event-logs":{"get":{"tags":["baton"],"summary":"Audit log","description":"Paginated audit log.","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"since","in":"query","schema":{"type":"string"}},{"name":"until","in":"query","schema":{"type":"string"}},{"name":"writer","in":"query","schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Audit log page."}}}},"/v1/baton/{id}/tokens":{"post":{"tags":["baton"],"summary":"Mint token","description":"Mint a collaborator token (read / write / read_write capability + per-token caps). Owner-only, free.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Token issued."}}},"get":{"tags":["baton"],"summary":"List tokens","description":"List tokens (includes revoked).","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Token list."}}}},"/v1/baton/{id}/tokens/{tokenId}":{"post":{"tags":["baton"],"summary":"Modify or revoke","description":"Modify (mutable subset) OR revoke a token. Owner-only, free.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"tokenId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Token modified / revoked."}}}},"/v1/baton/{id}/trust/document-witness":{"post":{"tags":["baton-trust"],"summary":"Witness","description":"Freeze + sign the baton. Conditionally billable ($0.05 standalone OR free on prepaid path).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Baton witnessed."},"409":{"description":"ALREADY_WITNESSED / BATON_NOT_ACTIVE / CHAIN_NOT_INITIALIZED."},"422":{"description":"witness_key_not_configured."}}}},"/v1/baton/{id}/trust/verify-chain":{"post":{"tags":["baton-trust"],"summary":"Verify chain","description":"Verify chained-hash integrity (modes: links | full). FREE.","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Chain status."}}}},"/v1/baton/{id}/trust/challenge":{"post":{"tags":["baton-trust"],"summary":"Challenge","description":"Match a candidate against a witnessed baton. PUBLIC, FREE, per-IP rate-limited.","security":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Match result."},"429":{"description":"Rate-limited."}}}},"/v1/baton/{id}/trust/proof":{"get":{"tags":["baton-trust"],"summary":"Get proof","description":"Retrieve the signed attestation. PUBLIC, FREE.","security":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Proof envelope."},"409":{"description":"NOT_WITNESSED."}}}},"/v1/cputools/upload-url":{"post":{"tags":["cputools"],"summary":"Mint a storage-ref upload URL","description":"Mint a customer-scoped presigned POST for the storage-ref input path — used to upload a file too large for the inline base64 field (> cputools.io.max_inline_bytes, 4 MiB) before passing { inputKey } to a PDF op. NOT billable (minting is free; the op that consumes the bytes bills). Auth: api-key / wallet-JWT / cookie, OR a verify-only x402 payment (the EIP-712 signature is verified but NOT settled — this lets an account-less x402 lodestone caller obtain a scoped key and transform files > 4 MB). The minted key is scoped to the resolved customer (scratch/<customerId>/<uuid>); a cputool only admits the caller's own keys. Returns an HTML-form-style multipart POST (Brief 149): the client POSTs multipart/form-data to `url` with every `fields` entry plus a `file` part — NOT a raw PUT. The upload is hard-capped at cputools.io.max_storage_ref_bytes (50 MiB) by the presigned-POST content-length-range.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"ext":{"type":"string","maxLength":12,"description":"Optional file extension, sanitized to [a-z0-9]{0,12}."},"contentType":{"type":"string","maxLength":255}}}}}},"responses":{"201":{"description":"{ url, fields, inputKey, expiresAt, maxBytes } — POST multipart/form-data (all fields + a `file` part) to `url`; then pass { inputKey } to a PDF op."},"401":{"description":"No account identity and no valid x402 signature."}}}},"/v1/pdf/merge":{"post":{"tags":["cputools"],"summary":"Merge PDFs","description":"Concatenate 2+ PDFs into one. Body: { files: [<source>, …], filename? } where each <source> is { inline: <base64> } (≤ 4 MiB decoded) or { inputKey: <scratch key from upload-url> }. Billed per output page. Output: { output: { inline?: <base64> | outputKey + outputUrl (presigned GET, when > 4 MiB), sizeBytes, contentType } }.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Merged PDF (uniform JSON output envelope)."},"402":{"description":"Payment required (x402 challenge with the body-derived price)."},"404":{"description":"A storage-ref inputKey is not owned by the caller."},"413":{"description":"A storage-ref input exceeds max_storage_ref_bytes."},"422":{"description":"INPUT_TOO_LARGE (inline > 4 MiB) / INPUT_EMPTY / PDF_PARSE_FAILED."}}}},"/v1/pdf/split":{"post":{"tags":["cputools"],"summary":"Split a PDF","description":"Split one PDF into multiple. Body: { file: <source>, ranges?: \"1-3,5\", burst?: true }. `burst` (or no ranges) = one file per page; `ranges` = one file per comma segment. Billed per SOURCE page. Capped at cputools.pdf.split.max_outputs (100). Output: a manifest { files: [{ index, outputKey, outputUrl (presigned GET), pages, sizeBytes }, …] } — never inline.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Split manifest of presigned-GET refs."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"413":{"description":"Input exceeds max_storage_ref_bytes."},"422":{"description":"BAD_RANGE / TOO_MANY_OUTPUTS / PDF_PARSE_FAILED."}}}},"/v1/pdf/rotate":{"post":{"tags":["cputools"],"summary":"Rotate pages","description":"Rotate pages by 90/180/270°. Body: { file: <source>, degrees: \"90\"|\"180\"|\"270\", pages?: \"all\"|\"1-3,5\" } (pages are 1-based; folds onto existing rotation). Billed per rotated page. Output: uniform JSON output envelope.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Rotated PDF."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"BAD_RANGE / PDF_PARSE_FAILED."}}}},"/v1/pdf/pages":{"post":{"tags":["cputools"],"summary":"Delete / reorder / insert pages","description":"Edit the page set. Body is a discriminated union on `action`: { action:\"delete\", file, pages:\"2,4-5\" } | { action:\"reorder\", file, order:[3,1,2] } | { action:\"insert\", file, insert:<source>, at:1 }. All page references are 1-based; insert `at`=N+1 appends. Billed per RESULT page. Output: uniform JSON output envelope.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Edited PDF."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"BAD_RANGE / PDF_PARSE_FAILED."}}}},"/v1/pdf/extract-text":{"post":{"tags":["cputools"],"summary":"Extract text","description":"Extract text via unpdf (a serverless pdf.js build). Body: { file: <source>, pages?: \"1-3,5\" }. Billed per source page. Output: JSON { totalPages, pages: [{ page, text }, …], text } — no bytes.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Extracted per-page + merged text."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"BAD_RANGE / PDF_PARSE_FAILED."}}}},"/v1/pdf/metadata":{"post":{"tags":["cputools"],"summary":"Get / set metadata","description":"Read or write document metadata. Body: { file: <source>, set?: { title?, author?, subject?, keywords?, creator?, producer? } }. No `set` → returns the metadata JSON; with `set` → returns the modified PDF (uniform JSON output envelope). Flat-billed (cputools.price.pdf.metadata.flat_micros).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Metadata JSON (get) or modified PDF (set)."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"PDF_PARSE_FAILED."}}}},"/v1/pdf/watermark":{"post":{"tags":["cputools"],"summary":"Watermark / stamp","description":"Stamp text on pages. Body: { file: <source>, text, opacity?:0-1, size?:int, color?:\"#RRGGBB\", position?:\"center\"|\"top-left\"|\"top-right\"|\"bottom-left\"|\"bottom-right\", pages?:\"all\"|\"1-3\" }. Billed per stamped page. Output: uniform JSON output envelope.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Watermarked PDF."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"BAD_RANGE / PDF_PARSE_FAILED."}}}},"/v1/pdf/form":{"post":{"tags":["cputools"],"summary":"Form-fill / flatten","description":"Fill AcroForm fields and/or flatten. Body: { file: <source>, fields?: { <name>: <string|boolean> }, flatten?: bool } — string → text/dropdown/radio, boolean → checkbox. Billed per page. Output: uniform JSON output envelope.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Filled/flattened PDF."},"402":{"description":"Payment required (x402 challenge)."},"404":{"description":"inputKey not owned by the caller."},"422":{"description":"FIELD_NOT_FOUND / FIELD_TYPE_MISMATCH / PDF_PARSE_FAILED."}}}},"/v1/qr":{"post":{"tags":["cputools"],"summary":"Generate a QR code","description":"Generate a QR code from a `data` string — no file input. Body: { data (≤ cputools.qr.max_data_len, 4096), format?: \"png\"|\"svg\" (default png), size?: 64-2048 (default 512), margin?: 0-16 (default 4), ecc?: \"L\"|\"M\"|\"Q\"|\"H\" (default M) }. FLAT price $0.0002 (cputools.qr.generate). Output: uniform JSON envelope { output: { inline: <base64 PNG | base64 SVG>, sizeBytes, contentType } } — always inline (QR images are small).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"QR image (uniform JSON output envelope)."},"400":{"description":"VALIDATION_ERROR (empty data, bad ecc/size/format)."},"402":{"description":"Payment required (x402 challenge with the $0.0002 static price)."},"422":{"description":"DATA_TOO_LONG (data exceeds cputools.qr.max_data_len)."}}}},"/v1/barcode":{"post":{"tags":["cputools"],"summary":"Generate a barcode","description":"Generate a linear/2D barcode via bwip-js — no file input. Body: { data, symbology (one of: code128, code39, ean13, ean8, upca, upce, itf14, qrcode, datamatrix, pdf417, azteccode), format?: \"png\"|\"svg\" (default png), scale?: 1-10 (default 3), height?: 1-200 (default 10) }. FLAT price $0.0002 (cputools.barcode.generate). Output: uniform JSON envelope (always inline).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"Barcode image (uniform JSON output envelope)."},"400":{"description":"VALIDATION_ERROR."},"402":{"description":"Payment required (x402 challenge with the $0.0002 static price)."},"422":{"description":"UNSUPPORTED_SYMBOLOGY / DATA_TOO_LONG."}}}},"/v1/account/api-keys/regenerate":{"post":{"tags":["account"],"summary":"Regenerate API key","description":"Regenerate the customer API key. Mints a new rs_live_* key, invalidates the prior one, returns the raw key ONCE. Dashboard-only (rs_session cookie OR wallet-JWT); API-key callers are rejected with 403 WALLET_JWT_OR_SESSION_REQUIRED so a compromised key cannot rotate itself. Rejects x402 with 400 X402_CANNOT_REGENERATE.","security":[{"walletJwt":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Optional reason logged in audit_log metadata. Truncated to 500 chars."}}}}}},"responses":{"200":{"description":"{ vfApiKey, apiKeyPrefix, regeneratedAt } — vfApiKey is the full raw secret, returned ONCE."},"400":{"description":"X402_CANNOT_REGENERATE."},"403":{"description":"WALLET_JWT_OR_SESSION_REQUIRED."},"502":{"description":"MINT_FAILED (Relaystation upstream 4xx)."},"503":{"description":"RELAYSTATION_UNAVAILABLE."}}}},"/v1/bridge/ask":{"post":{"tags":["bridge"],"summary":"Ask (blocking)","description":"Send a question to the human operator and enqueue a pending_ask for reply. Returns a ticket the agent (or the @relaystation/mcp wrapper) polls via GET /v1/bridge/ask/{ticket}/check. Free tier: 5 telegram + 2 email/day per customer. Over-cap calls debit at bridge.price.<channel>_message_micros. x402-authed first calls auto-create the customers row (kind=wallet). When the bridge is toggled off (set_bridge_active=false), returns 200 {status: \"bridge_inactive\"} without enqueueing.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["channel","message"],"properties":{"channel":{"type":"string","enum":["telegram","email","sms"]},"message":{"type":"string","minLength":1,"maxLength":65536},"max_wait_seconds":{"type":"integer","minimum":1,"maximum":86400},"attached_baton_id":{"type":"string"},"callback_url":{"type":"string","format":"uri","description":"Optional webhook delivery target for headless agents (mode-2). Fires bridge.ask.replied event on the existing outbox when a reply lands."}}}}}},"responses":{"200":{"description":"{ticket, status, expires_at, cascade?} — status is pending OR bridge_inactive OR free_tier_only on soft-fail paths.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST (invalid body)."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE. The latter is returned when an X-Payment header is present but the EIP-712 typed-data signature fails to recover to the claimed payer wallet (or the payload is malformed / expired / for the wrong asset). Body carries `{error, code: \"INVALID_X402_SIGNATURE\", reason}` where reason mirrors the chassis AdmitFailReason enum."},"402":{"description":"PAYMENT_REQUIRED (x402 admit rejection on over-cap path)."},"429":{"description":"rate_capped."}}}},"/v1/bridge/ask/{ticket}/check":{"get":{"tags":["bridge"],"summary":"Check ask status","description":"Poll one pending_ask. Returns status (pending/replied/expired/bridge_inactive/free_tier_only), reply on replied, and next_poll_after_seconds — the @relaystation/mcp wrapper sleeps for that interval before re-polling. Server-resolved schedule (paid customers get the fine schedule; free-tier get coarse). Customer-scoped — returns 404 when the ticket belongs to a different customer (avoids leaking ticket existence).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"ticket","in":"path","required":true,"schema":{"type":"string","pattern":"^ask_[a-f0-9-]+$"}}],"responses":{"200":{"description":"{ticket, status, reply?: {body, received_at}, expires_at, next_poll_after_seconds}","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE (same shape as POST /v1/bridge/ask). A forged X-Payment header cannot read another customer's reply."},"404":{"description":"Ticket not found (or belongs to another customer)."}}}},"/v1/bridge/missed":{"get":{"tags":["bridge"],"summary":"List recently-replied tickets since a timestamp","description":"Brief 152 — supports Last-Event-ID reconnect-replay on the courier-push-server. Reads up to 100 of the authenticated customer's pending_ask rows where status='replied' AND replied_at > <since>, ordered ASC. Customer-scoped — a forged Last-Event-ID cannot leak cross-customer replies because the bearer-resolved customerId is the join key on the server side. Result cap (100) bounds long-disconnect replay storms.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"since","in":"query","required":true,"description":"Unix milliseconds. Returns rows with replied_at strictly newer than this timestamp.","schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"{count: N, events: [{ticket, reply: {body, received_at}, tier, cascade, replied_at_ms}]}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST — missing or malformed since query param."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE (same shape as POST /v1/bridge/ask)."}}}},"/v1/bridge/notify":{"post":{"tags":["bridge"],"summary":"Notify (fire-and-forget)","description":"Send a one-way status update to the operator. No reply expected. Same free-tier and pricing as ask. Soft-fail on insufficient_funds: returns 200 {status: \"free_tier_only\"} so the agent's continuation surfaces upgrade copy rather than a hard 402.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["channel","message"],"properties":{"channel":{"type":"string","enum":["telegram","email","sms"]},"message":{"type":"string","minLength":1,"maxLength":65536},"attached_baton_id":{"type":"string"}}}}}},"responses":{"200":{"description":"{status: \"sent\" | \"bridge_inactive\" | \"free_tier_only\", message_id, cascade?}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE (same shape as POST /v1/bridge/ask)."},"402":{"description":"PAYMENT_REQUIRED (x402 admit rejection)."},"429":{"description":"rate_capped."}}}},"/v1/bridge/active":{"get":{"tags":["bridge"],"summary":"Read bridge_active toggle","description":"Read the customer's bridge on/off state. Lazy-inserts a bridge_channel_state row with defaults on first read for x402/wallet-identity callers (operator-set default = true per bridge.active.default).","security":[{"apiKey":[]},{"walletJwt":[]}],"responses":{"200":{"description":"{active, default_was, updated_at}","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"UNAUTHENTICATED."}}},"post":{"tags":["bridge"],"summary":"Set bridge_active toggle","description":"Flip the bridge on/off state for the customer. Operator-controlled (typically toggled from the dashboard or the Telegram bot's /active command). When false, ask/notify return 200 {status: \"bridge_inactive\"} without enqueueing or charging.","security":[{"apiKey":[]},{"walletJwt":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["active"],"properties":{"active":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{active, updated_at}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST."},"401":{"description":"UNAUTHENTICATED."}}}},"/v1/bridge/cascade/{action}":{"post":{"tags":["bridge"],"summary":"Dispatch cross-product cascade action","description":"Dispatch a registered cross-product action by name (e.g., the cascade-button taps a customer makes from an inline keyboard). Each action handler owns its own billing — the dispatcher does NOT wrap in withCharge. Currently registered: `noop` (chassis-side smoke probe). Unknown action names return 400 UNKNOWN_ACTION. **x402 is NOT supported on this route** — ActionContext does not thread the X-Payment payload through to handlers, so an x402-authed dispatch cannot admit the customer's signed authorization. Any request with an X-Payment header returns 400 X402_NOT_SUPPORTED_ON_CASCADE; the route requires api-key Bearer or wallet-JWT Bearer auth. The first cross-product consumer brief will widen ActionContext + remove this guard.","security":[{"apiKey":[]},{"walletJwt":[]}],"parameters":[{"name":"action","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":100}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"params":{"type":"object","additionalProperties":true}}}}}},"responses":{"200":{"description":"{ok: true, data: {...action-specific...}}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"{ok: false, error: \"UNKNOWN_ACTION\", reason} for an unregistered action name, OR {error, code: \"X402_NOT_SUPPORTED_ON_CASCADE\", reason} when an X-Payment header is present (cascade dispatcher does not accept x402 yet)."},"401":{"description":"UNAUTHENTICATED."}}}},"/v1/bridge/cascade/dismiss":{"post":{"tags":["bridge"],"summary":"Dismiss a cascade template","description":"Mute a specific cascade template for the customer for `bridge.cascade.frequency.muted_after_dismiss` seconds (default 24h). UPSERTs bridge_cascade_log.dismiss_until_at. Dedicated route — NOT routed through the cross-product dispatcher (dismiss is bridge-internal, not a cross-product action).","security":[{"apiKey":[]},{"walletJwt":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["template_key"],"properties":{"template_key":{"type":"string","description":"Matches bridge.cascade.template.<key> suffix (e.g. \"telegram_too_big\").","minLength":1,"maxLength":100}}}}}},"responses":{"200":{"description":"{dismissed_until} — ISO-8601 timestamp.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST."},"401":{"description":"UNAUTHENTICATED."}}}},"/v1/bridge/sms/set-phone":{"post":{"tags":["bridge"],"summary":"Set the operator SMS phone number","description":"Operator pre-configures the E.164 phone number the bridge-send-worker uses for outbound SMS. Account management, NOT per-call billable — customer-auth only (api-key / wallet-JWT), no x402. Idempotent UPSERT on (customer_id). Returns 503 SMS_NOT_CONFIGURED while the SMS gateway is dormant (TFN approval pending); once the gateway is live, the same call stores the phone.","security":[{"apiKey":[]},{"walletJwt":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone_e164"],"properties":{"phone_e164":{"type":"string","description":"E.164 format, e.g. +14155550123."}}}}}},"responses":{"200":{"description":"{ok: true, phone_e164}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST (invalid E.164 format)."},"401":{"description":"UNAUTHENTICATED."},"503":{"description":"SMS_NOT_CONFIGURED — the SMS gateway is dormant pending carrier (TFN) approval."}}}},"/v1/bridge/email/confirm":{"get":{"tags":["bridge"],"summary":"Confirm operator email opt-in","description":"The operator clicks the confirm link in the one-time email Courier sends; this flips `bridge_channel_state.email_opted_in = true` so agents can send the operator bridge messages by email. The `t` query param is a single-use JWT (aud=bridge-email-confirm, sub=customerId, jti consumed on first use so a leaked link cannot be reused). Returns a small inline HTML confirmation page — not JSON. PUBLIC route (the link carries its own signed token; no session/api-key needed — a human clicks it from their inbox).","security":[],"parameters":[{"name":"t","in":"query","required":true,"schema":{"type":"string"},"description":"Single-use confirm JWT minted at email-send time."}],"responses":{"200":{"description":"Inline HTML — \"Email confirmed\"."},"400":{"description":"Inline HTML — missing / invalid / expired / malformed confirm token."}}}},"/v1/bridge/agent-address":{"post":{"tags":["bridge"],"summary":"Mint an ephemeral agent address","description":"Mint a random `<local_part>@courier.relaystation.ai` ephemeral address for agent-to-agent messaging (~67 bits of entropy in the local part — unguessable, so cross-customer spam is mathematically infeasible). Free up to `bridge.agent_address.daily_mint_cap_per_customer` per UTC day; above-cap mints debit `bridge.agent_address.create` (x402-authed first calls auto-create the customers row). Idempotency-Key required on the over-cap (billable) path.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"ttl_seconds":{"type":"integer","minimum":60,"maximum":2592000,"description":"Address lifetime; defaults to bridge.agent_address.ttl_default_seconds (86400)."},"purpose_label":{"type":"string","maxLength":100,"description":"Optional human label for the address."}}}}}},"responses":{"200":{"description":"{id, address, local_part, purpose_label, ttl_seconds, expires_at, status}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE (same shape as POST /v1/bridge/ask)."},"402":{"description":"PAYMENT_REQUIRED (x402 admit rejection on the over-cap path)."}}}},"/v1/bridge/agent-addresses":{"get":{"tags":["bridge"],"summary":"List my agent addresses","description":"List this customer's active (not-yet-expired) ephemeral agent addresses. Free (no per-call charge). Customer-auth (api-key / wallet-JWT / x402).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"responses":{"200":{"description":"{addresses: [{id, address, local_part, purpose_label, ttl_seconds, expires_at, status}]}","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE."}}}},"/v1/bridge/message-agent":{"post":{"tags":["bridge"],"summary":"Message another agent","description":"Send a message to another Relaystation agent's ephemeral address. Internal routing only — same-fleet AND cross-customer delivery use the internal agent_messages table (no external email round-trip). Free (no per-call charge). External (non-courier.relaystation.ai) domains return EXTERNAL_DOMAIN_NOT_SUPPORTED_V2 (Route 3 / agent→external is deferred to v2.1).","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to_address","body"],"properties":{"to_address":{"type":"string","format":"email","description":"A `<local_part>@courier.relaystation.ai` address."},"body":{"type":"string","minLength":1,"maxLength":65536},"subject":{"type":"string","maxLength":200},"attached_baton_id":{"type":"string"}}}}}},"responses":{"200":{"description":"{ok: true, message_id, delivered_at}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST / INVALID_ADDRESS / EXTERNAL_DOMAIN_NOT_SUPPORTED_V2."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE."},"404":{"description":"UNKNOWN_ADDRESS (no active address for that local part)."},"429":{"description":"QUOTA_EXCEEDED (recipient mailbox at bridge.mailbox.max_messages_per_address)."}}}},"/v1/bridge/mailbox/{address_id}":{"get":{"tags":["bridge"],"summary":"Read an agent mailbox","description":"Read inbound agent_messages for an ephemeral address THIS customer owns (ownership enforced; a foreign address_id returns 404). Free. With `unread_only=true`, returns only unread messages and stamps `read_at=now()` on them — clears a polling agent's backlog.","security":[{"apiKey":[]},{"walletJwt":[]},{"x402":[]}],"parameters":[{"name":"address_id","in":"path","required":true,"schema":{"type":"string","pattern":"^aa_[a-z0-9]+$"}},{"name":"unread_only","in":"query","schema":{"type":"boolean"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"{messages: [{id, subject, body_text, from, attached_baton_id, created_at, read_at}]}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST (malformed address id)."},"401":{"description":"UNAUTHENTICATED OR INVALID_X402_SIGNATURE."},"404":{"description":"NOT_FOUND (address not owned by this customer)."}}}},"/v1/admin/courier/refund-star-payment":{"post":{"tags":["bridge"],"summary":"Refund a Telegram Stars payment (admin)","description":"OPERATOR-ONLY. Refunds a Telegram Stars top-up via the chassis refund helper (`refundStarPayment`) and writes a reversing ledger credit. Requires an admin session (rs_admin_session cookie, admin_users allowlist) — this is NOT a customer-callable route; it is documented here only so the full Courier surface is discoverable. Idempotent on `telegram_payment_charge_id` (a re-run returns `already_refunded: true`).","security":[{"adminSession":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["telegram_payment_charge_id"],"properties":{"telegram_payment_charge_id":{"type":"string","minLength":1,"maxLength":200},"reason":{"type":"string","maxLength":2000}}}}}},"responses":{"200":{"description":"{ok: true, already_refunded, ledger_id, customer_id, amount_micros}","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"BAD_REQUEST or a refund-failure reason code."},"401":{"description":"Admin session required."},"404":{"description":"charge_not_found."}}}}}}