# USDReceipt API **Digital dollar infrastructure for humans and agents.** Non-custodial USDT / USDC / native-ETH payment infrastructure on Ethereum mainnet. Create checkouts and invoices, let buyers pay your wallet directly, reconcile through receipts + CSV + VAT exports, send funds back out — all through a REST API that's mirrored 1:1 by an in-app chat agent and an MCP server. Same data model, same auth, same audit trail, two ways to drive it. > **Non-custodial by design.** We never touch funds. We read the chain, generate receipts, and emit webhooks. Your buyers pay your wallet directly. There is no custody, no settlement, no key escrow. > > **Self-compliant by design.** USDReceipt is not a money transmitter, MSB, payment institution, or VASP. KYC / AML / sanctions / jurisdictional reporting / tax filings are your responsibility — we give you complete, audit-ready data (receipts with on-chain proofs, signed PDFs, CSV exports, per-jurisdiction VAT reports, webhooks at every state change) and stay out of your regulatory perimeter. The deliberate absence of any `seed_phrase` or `private_key` field in this API surface is part of that contract: outbound signing always happens in the merchant's browser, never in our memory. - **Base URL:** `https://usdreceipt.xyz` - **All requests and responses use JSON.** - **Auth:** every authenticated endpoint requires an API key passed as a Bearer token, except a small set of public endpoints used by buyers on payment pages. - **Dual-surface:** every REST endpoint that creates / reads / mutates payment data is also exposed as a chat-agent tool and, where it makes sense, as an MCP tool. See the [README](https://github.com/bitfent/usdreceipt.xyz#for-humans-for-agents--the-dual-surface) for the per-feature parity table. - **This document is generated from the OpenAPI spec at `/api/openapi.json`.** A downloadable copy lives at `/docs.md` and the same body is served inline at `/llms-full.txt` for AI editors. --- ## Quickstart in 60 seconds The same three steps the dashboard's snippet panel will paste for you after creating a key: ``` USDR_BASE_URL=https://usdreceipt.xyz USDR_API_KEY=usdr_test_... ``` ```bash curl -sS "$USDR_BASE_URL/api/v1/payment-requests?limit=1" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ```ts const res = await fetch(`${process.env.USDR_BASE_URL}/api/v1/payment-requests?limit=1`, { headers: { authorization: `Bearer ${process.env.USDR_API_KEY}` }, }); console.log(await res.json()); ``` Then create your first checkout, share the URL with a buyer, and verify the on-chain payment: ```bash # 1. Create a checkout curl -sS -X POST "$USDR_BASE_URL/api/v1/checkouts" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Premium Plan", "amount_usdt": "49.99", "recipient_address": "0xYourRegisteredWallet" }' # -> { "ok": true, "checkout": { "id": "chk_...", "url": "https://...pay/chk_..." } } # 2. Buyer pays. You verify the tx on-chain (or instantly with a test key): curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/chk_abc123/verify" \ -H "Content-Type: application/json" \ -d '{ "tx_hash": "0xabc...64hex" }' # -> { "ok": true, "receipt": { ... } } ``` With a test key (`usdr_test_…`), any valid-looking tx hash (`0x` + 64 hex chars) is accepted instantly — no real transactions needed. With a live key (`usdr_live_…`), the transaction is verified against Ethereum mainnet. A PDF receipt is emailed to the buyer if their email is provided. --- ## Authentication Create an API key from the [API page](/frontend/api.html). Then pass it in the `Authorization` header: ``` Authorization: Bearer usdr_a1b2c3d4e5f6... ``` Every key has a [scope](#scopes) that controls which endpoints it may call, and a [mode](#test-mode-vs-live-mode) (`live` or `test`) that controls whether it touches mainnet. The dashboard itself uses passwordless magic-link login via email; that session has full access to all features and is never restricted by scope. --- ## Scopes Every API key carries a **scope** that determines which endpoints it can call. Scopes are hierarchical: a higher scope can do everything a lower scope can. When the dashboard creates a key you choose between three scopes (`read_only`, `payments`, `user`). The fourth scope, `full`, is reserved for internal/admin use and **cannot** be created from the UI or via the REST API — this guarantees that a leaked or shared key can never escalate to manage other keys, change your profile, add/remove wallets, or write coupons. ### Scope levels - **`read_only`** — GET-only access to your own data. Reads profile, payment requests, receipts, coupons, wallets, transactions, and CSV exports. Cannot create, void, verify, or modify anything. Ideal for analytics, dashboards, BI tools, or "watcher" agents that should never write. - **`payments`** — everything `read_only` can do, **plus** creating checkouts and invoices and verifying on-chain payments. Cannot void payment requests or change configuration. Ideal for embedded checkout widgets, e-commerce integrations, or payment-collection agents. - **`user`** (default for UI / agent keys) — everything `payments` can do, **plus** voiding payment requests and using the test-mode simulator. Still cannot modify wallets, profile, coupons, or other API keys. Ideal for full-featured automation agents that manage the day-to-day lifecycle of payments. - **`full`** — admin-only. Manages API keys, profile, wallets (add/verify/remove), coupons (create/edit/delete), and reads usage analytics. Used internally by the dashboard session. **Never** issued from the dashboard or the REST API. ### Insufficient scope Calling an endpoint with a key that does not meet its required scope returns: ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'payments' or higher", "required_scope": "payments", "current_scope": "read_only" } ``` The minimum required scope for every endpoint is shown in this document directly under each endpoint's heading. --- ## Organizations & roles Every USDReceipt account belongs to at least one **organization** (org). Orgs are the unit of resource ownership — wallets, payment requests, receipts, API keys, webhooks, compliance profiles, audit log entries all live inside an org. When you sign up, a **personal organization** is auto-created with you as Owner. You can create additional orgs for teams, side projects, or separate businesses, and invite teammates with one of five roles. ### The five roles Roles are ranked. Each role inherits every permission of the roles below it. | Role | Power level | What it can do | |---|---|---| | **Owner** | 5 | Everything. Only role that can archive the org, transfer ownership, or set org-level spending caps. Every org has ≥ 1 Owner at all times — demoting or removing the last Owner is rejected (`LAST_OWNER_PROTECTED`). | | **Admin** | 4 | Invites and removes members, sets per-member spending caps, edits business info. Cannot archive the org. | | **Finance** | 3 | Moves money: creates checkouts and invoices, sends funds, manages payouts and compliance profiles. The day-to-day finance role. | | **Developer** | 2 | Manages API keys, webhooks, integrations, and reads the audit log. Cannot move money or invite members. | | **Analyst** | 1 | Read-only across the org: receipts, payments, CSV exports, members list. Cannot read the audit log, cannot move money. | ### Permission matrix Every server-side action has a minimum required role. The full matrix: | Action | Min role | |---|---| | `org.view`, `members.view`, `wallets.view`, `receipts.read`, `csv.export` | analyst | | `org.edit_settings`, `org.set_spending_caps`, `members.invite`, `members.remove`, `members.set_spending_caps`, `wallets.remove`, `wallets.generate`, `approvals.grant` | admin | | `org.delete`, `org.transfer_ownership` | owner | | `payments.create_checkout`, `payments.create_payroll`, `payments.send_funds`, `payments.build_tx`, `wallets.add`, `compliance.manage` | finance | | `keys.manage`, `webhooks.manage`, `approvals.view`, `audit_log.read` | developer | When an action is denied because the role is too low, you get a 403: ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer" } ``` ### Roles compose with scopes — both must pass Every authenticated request runs through **two** authorization layers: 1. **Scope** — does the API key have the required scope? (See [Scopes](#scopes) above.) 2. **Role** — does the caller's role in the active org permit this action? The effective permission is the **min** of the two. A `developer`-role user using a `read_only` key can still only read, even though Developer normally permits writes to the dev surface. Conversely, an admin with a `read_only` key cannot invite members through that key — they'd need to use a higher-scoped key or the dashboard session. This composition means you can grant a `read_only` key to an internal BI tool without worrying about role-level permissions leaking write access. ### Active organization Every authenticated request runs against exactly one org — the **active org**. How it's resolved: - **Session cookie callers** (dashboard) — `sessions.active_org_id`. Flip it with `POST /api/v1/orgs/:id/switch`. - **API-key callers** — every key is pinned to the org it was created in (`api_keys.org_id`). To act on a different org, create a key inside that org. - **OAuth JWT callers** — the user's personal org by default (v1; per-token org scoping is roadmap). Read the current active org with `GET /api/v1/orgs/current`. List all orgs you belong to with `GET /api/v1/orgs`. ### Personal orgs When you sign up, a personal org is created automatically with name `"'s Workspace"` and you as the sole Owner. It's a regular org with two extra properties: - `is_personal: true` in API responses. - Cannot be archived (`PERSONAL_ORG_PROTECTED`) — your sign-in is permanently tethered to it. You can still create additional non-personal orgs alongside it, switch freely between them, and accept invites to other people's orgs. The personal org just guarantees that every account always has at least one org context, which simplifies onboarding and avoids "orphaned user" states. ### Invites & onboarding flows Invites are sent by email and bound to that email address. The link in the email routes the recipient through the magic-link sign-in flow with the invite token attached, then auto-joins them on first sign-in. Two cases at sign-in time: - **New user clicking an invite link** — gets signed in, joins the inviting org with the assigned role, and **does NOT** get a personal org auto-created. (They can opt into one later via `POST /api/v1/orgs` with `is_personal:true` if needed.) - **Existing user clicking an invite link** — gets a new membership in the inviting org. Their personal org and other memberships are unaffected. Wrong-email guard: if you click an invite link while signed in as a different email, accept fails with `INVITE_EMAIL_MISMATCH`. Sign out and back in with the invited email, or accept from `GET /api/v1/me/invites` while signed in correctly. Invites expire after 7 days. Refreshing an invite for the same email + same org is a no-op that updates the existing row's expiry (same token returns to the inviter), preventing duplicate-row sprawl. ### Quick reference ```bash # List all orgs you belong to + which is active curl -H "Authorization: Bearer $USDR_API_KEY" \ "$USDR_BASE_URL/api/v1/orgs" # Get just the active org + your role in it curl -H "Authorization: Bearer $USDR_API_KEY" \ "$USDR_BASE_URL/api/v1/orgs/current" # Create a new org (you become Owner) curl -X POST -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"My Side Project"}' \ "$USDR_BASE_URL/api/v1/orgs" # Invite a teammate by email curl -X POST -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","role":"developer","message":"Welcome to the team!"}' \ "$USDR_BASE_URL/api/v1/orgs/$ORG_ID/invites" # Change a member's role (subject to role ladder) curl -X PATCH -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"role":"finance"}' \ "$USDR_BASE_URL/api/v1/orgs/$ORG_ID/members/$USER_ID" # Read the org's audit log curl -H "Authorization: Bearer $USDR_API_KEY" \ "$USDR_BASE_URL/api/v1/orgs/$ORG_ID/audit-log?limit=50" # See your pending invites (across all orgs you've been invited to) curl -H "Authorization: Bearer $USDR_API_KEY" \ "$USDR_BASE_URL/api/v1/me/invites" # Switch active org (only effective for session callers — see "Active organization" above) curl -X POST -H "Authorization: Bearer $USDR_API_KEY" \ "$USDR_BASE_URL/api/v1/orgs/$ORG_ID/switch" ``` --- ## Test mode vs live mode Every API key has a mode — you can tell from the key prefix: | Prefix | Mode | Behaviour | |---|---|---| | `usdr_live_` | Live | Full on-chain verification against Ethereum mainnet. | | `usdr_test_` | Test | Mock verification — any valid-looking tx hash is accepted instantly. The simulator endpoint (`POST /api/v1/payment-requests/{id}/simulate-pay`) generates `simulated=1` receipts. | **Test and live data are completely isolated.** Checkouts created with a test key only appear when listing with a test key (or via the dashboard's test toggle). Receipts, CSV exports, payment requests, and webhook deliveries all follow the same isolation. ### Cross-mode guard If a Bearer-authenticated request crosses the test/live line — for example, a `usdr_test_` key trying to void a live-mode payment request — the API rejects it with `403 LIVE_TEST_MODE_MISMATCH` and an explanation of which key prefix to use instead. Session callers (the dashboard) are first-party and can act on either mode. --- ## Error model All errors return a consistent JSON structure: ```json { "ok": false, "error": "ERROR_CODE", "message": "Human-readable description" } ``` Common error codes: | Code | Meaning | |---|---| | `UNAUTHORIZED` | Missing or invalid API key / session. | | `INSUFFICIENT_SCOPE` | Key scope is too low for this endpoint. Response also includes `required_scope` and `current_scope`. | | `WALLET_NOT_REGISTERED` | API-key caller used a `recipient_address` that is not registered on the account. | | `LIVE_TEST_MODE_MISMATCH` | Test key acted on a live PR (or vice versa). | | `LIVE_MODE_NO_SIMULATION` | Tried to use `/simulate-pay` against a live-mode PR or with a live key. | | `INVALID_INPUT` | Missing or malformed request body. | | `INVALID_ETH_ADDRESS` | Not a valid Ethereum address. | | `PAYMENT_REQUEST_NOT_FOUND` | Checkout / invoice does not exist. | | `TRANSACTION_NOT_FOUND` | Tx hash not found on chain. | | `PAYMENT_AMOUNT_TOO_LOW` | Underpayment beyond the 1% tolerance. | | `TRANSACTION_ALREADY_USED` | This tx hash was already used for a receipt. | | `WAITING_FOR_CONFIRMATIONS` | Transaction found but needs more block confirmations. | | `RATE_LIMITED` | See [Rate limits](#rate-limits). | | `INSUFFICIENT_ROLE` | Role in the active org is too low for this action. Includes `required_role`, `current_role`, and `required_action`. See [Organizations & roles](#organizations--roles). | | `NO_ORG_CONTEXT` | Authenticated but has no membership in any org. Only possible for orphaned legacy accounts; shouldn't appear post-migration. | | `NOT_AN_ORG_MEMBER` | Endpoint targets an org (`/api/v1/orgs/:id/...`) the caller is not a member of. | | `ROLE_LADDER_VIOLATION` | Caller tried to assign or invite to a role above their own. | | `LAST_OWNER_PROTECTED` | Tried to demote or remove the last Owner. Promote another member first. | | `PERSONAL_ORG_PROTECTED` | Tried to archive a personal org. Personal orgs are tethered to the user account and cannot be deleted while the user exists. | | `INVITE_NOT_FOUND` / `INVITE_EXPIRED` / `INVITE_ALREADY_RESOLVED` | Invite lookup/lifecycle failures. | | `INVITE_EMAIL_MISMATCH` | Tried to accept an invite while signed in as a different email than the one the invite was sent to. | | `CANNOT_INVITE_OWNER` | Owner role cannot be granted via invite. Promote an existing member to Owner instead. | --- ## Settlement model Checkouts and invoices share the same `payment_requests` table and the same `status` column. What flips a request from `open` to `paid` is the cumulative receipt amount crossing the requested amount (with a 1e-6 epsilon for base-unit rounding). After that, the request rejects all further verifies and attaches with `PAYMENT_REQUEST_ALREADY_PAID`. There are three write paths to a receipt. Their minimums differ: | Write path | Caller | Minimum single tx | Partial accumulation? | |---|---|---|---| | `POST /api/v1/payment-requests/{id}/verify` | Buyer page or your server | ≥ 99% of `amount_usdt` (else `PAYMENT_AMOUNT_TOO_LOW`) | **No** — the tx never becomes a receipt. | | Background wallet-watcher auto-match | Indexer job, when `ETHERSCAN_API_KEY` is set | ≥ 99% of `amount_usdt` (else lands in the Unmatched inbox) | **No** at the auto-match layer. | | `POST /api/v1/wallets/{id}/unmatched/{transfer_id}/attach` | Merchant, from the Unmatched inbox | **Any amount** — no on-chain re-check | **Yes**, and only meaningfully exposed for checkouts. | The takeaway for integrators: - **A buyer cannot underpay through `/verify`.** Any single transfer below 99% of the requested amount returns `PAYMENT_AMOUNT_TOO_LOW`, and no receipt is created. Build your buyer UI on the assumption that `/verify` either succeeds or fails — never half-succeeds. - **Within ±1% the watcher auto-matches as full.** The recorded `amount_usdt` on the receipt is the actual received amount, but the request flips to `paid`. - **Partial settlement is a reconciliation feature.** A checkout sitting at `effective_status='in_use'` with one or more receipts means someone has been attaching transfers from the merchant's Unmatched inbox. There is no public buyer-driven path to that state. - **Only checkouts surface `in_use`.** Invoices don't have a partial derived state, and every buyer-facing path requires a near-full single payment for them. Treat invoices as one-shot. - **`auto_archive_on_paid` only affects what happens *after* `paid`.** Set on a checkout, it soft-archives the request the moment it settles (so single-shot POS sales don't clutter the list). It doesn't change when settlement happens. - **`collect_buyer_info` is a UX flag, not a settlement flag.** It only toggles whether the buyer page renders Name/Email inputs and whether `/verify` requires `client_email`. Settlement decisions never read it. Concrete examples (all against a 10 USDC checkout): - Buyer pays 10 USDC exactly → watcher auto-matches → receipt → `paid`. - Buyer pays 9.95 USDC → within 1% → watcher auto-matches as full → `paid` (receipt records 9.95). - Buyer pays 9.00 USDC → watcher → Unmatched. `/verify` would reject as `PAYMENT_AMOUNT_TOO_LOW`. Merchant attaches → receipt #1 (9.00), status stays `open`, `effective_status` = `in_use`. A later inbound 1.00 USDC can be attached the same way → cumulative = 10 → flips to `paid`. --- ## Outbound transfers — there is no signing endpoint USDReceipt is non-custodial *by deliberate omission*: there is no REST endpoint that accepts a seed phrase, private key, or signed-but-not-yet-broadcast transaction. The send flow is entirely browser-side. - **Dashboard:** `Send` button on each wallet row at `/frontend/dashboard.html` opens a modal that signs through the merchant's connected EIP-1193 wallet (MetaMask, Coinbase Wallet, Rainbow, etc.) or, opt-in, derives a one-shot signer from a pasted seed phrase / private key entirely in the browser using a self-hosted ethers UMD bundle. - **Chat agent:** the `send_funds` tool inside `/chat` opens the same modal pre-filled with whatever fields the merchant gave the agent (`from_address` required; `token`, `recipient_address`, `amount` optional). The tool itself never receives the seed/key — those only exist inside the modal. After the merchant broadcasts, the tx hash is posted back as the next user message so the agent can confirm. - **MCP / external clients:** `send_funds` is **not** exposed via MCP. MCP hosts can't render the typed `send_intent` card, so the merchant couldn't actually sign. If you're integrating from outside the in-app chat, sign with your own key locally and submit the resulting transaction hash to the chain yourself. If you find yourself wanting an `/api/v1/wallets/.../send` endpoint that takes a `seed_phrase` body field, stop — that would put a master key in transit and break the non-custodial framing. The right escape hatch is to build and sign locally with the existing public node tooling. --- ## Rate limits Mutating endpoints are protected by rolling-window rate limits. When you exceed one you receive `429 RATE_LIMITED` with a `Retry-After` response header (seconds). | Endpoint | Limit | Window | |---|---|---| | `POST /api/v1/api-keys` | 10 requests | per hour, per user | | `POST /api/v1/payment-requests/{id}/verify` | 30 requests | per minute, per payment request | | `GET /api/v1/wallets/{id}/transactions` | 10 requests | per minute, per wallet | | `POST /api/v1/wallets/generate` | 20 requests | per hour, per API key (or per user) | Per-key usage is tracked in `api_usage_log` and exposed via `GET /api/v1/usage/keys` (list of keys with totals) and `GET /api/v1/usage/summary?api_key_id=…` (per-key time series + endpoint breakdown). Both routes require a session (or a `full` scope) — they are not exposed to UI-creatable keys to prevent introspection of other keys. --- ## Agent integrations USDReceipt is designed agent-first. There are three ways to drive it from an LLM or automation script: 1. **REST API** — every endpoint in this document. Use a `usdr_test_` key to develop locally, switch to `usdr_live_` for production. 2. **MCP server** — a curated [Model Context Protocol](https://modelcontextprotocol.io) surface (`/mcp` over JSON-RPC, plus a stdio CLI). Tools mirror the REST API but are gated by the same scopes. See [`mcp/README.md`](https://github.com/usdreceipt/usdreceipt/tree/main/mcp) in the repo. 3. **Built-in chat agent** — every authenticated user gets an in-product chat UI at [/frontend/chat.html](/frontend/chat.html) that calls the same tools on their behalf. The chat endpoint itself is internal and not part of this REST API. --- ## Connecting to AI assistants USDReceipt installs as an MCP app in every major AI host. Pick the path that matches where you'll be doing the work: | Client | Auth model | What you paste | |---|---|---| | **Claude** (web — claude.ai/settings/apps) | OAuth 2.1 + PKCE | The MCP URL — magic-link sign-in, no key copy-paste | | **Cursor** | Bearer token (`usdr_*`) | A small `mcpServers` JSON block with `Authorization: Bearer …` header | | **Claude Desktop** | Bearer token (`usdr_*`) | Same `mcpServers` JSON block in `claude_desktop_config.json` | | **stdio (any MCP host)** | Bearer token (`usdr_*`) via `bunx @usdreceipt/mcp` | Stdio config that reads `USDR_API_KEY` from env | For a one-click experience, open the [API page](/frontend/api.html) and use the **Connect Agent** modal — it has dedicated tabs for each client with copy-paste configs and (for Claude) a deep link to the right setup screen. The in-product [chat agent](/frontend/chat.html) can also walk you through it: ask "Add this to Claude" and it renders a rich app card. > **Note (May 2026):** ChatGPT custom-app support is temporarily disabled. OpenAI's MCP client has a known bug that hangs the conversation after a successful OAuth handshake. We'll re-enable it once OpenAI ships a fix. Use Claude or Cursor in the meantime. **OAuth scopes:** Claude.ai (and other spec-compliant MCP hosts) mint short-lived JWT bearers with one of two scopes: - `mcp.read` — list/get/search tools only (read-only access). - `mcp.write` — everything `mcp.read` can do, plus create checkouts/invoices, attach transfers, and manage recurring templates. Cannot modify your wallets or other API keys (mirrors the dashboard's `user` scope). Discovery endpoints (RFC 8414 / 9728) are at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`. Public signing keys are at `/oauth/jwks.json`. --- ## API reference The sections below are generated directly from the OpenAPI spec. Anything new added to the spec appears here automatically — there is no second source of truth to drift. ## Health {#tag-health} Server health ### GET /api/v1/health {#get-api-v1-health} **Health check** *Auth: Public* *Operation ID: `healthCheck`* **Responses** - `200` — Server is healthy **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/health" ``` ## Auth {#tag-auth} Magic link authentication ### POST /api/v1/auth/logout {#post-api-v1-auth-logout} **Log out** *Auth: Public* *Operation ID: `logout`* **Responses** - `200` — Session cleared **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/auth/logout" ``` ### POST /api/v1/auth/magic-link {#post-api-v1-auth-magic-link} **Request a magic link** Sends a passwordless login email. If the email isn't registered, a new account is created automatically. Always returns 200 to prevent email enumeration. *Auth: Public* *Operation ID: `requestMagicLink`* **Request body** (`application/json`) *(required)* - `email` (string) *(required)* — example: `"alice@example.com"` **Responses** - `200` — Magic link sent - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/auth/magic-link" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/auth/magic-link/verify {#post-api-v1-auth-magic-link-verify} **Verify a magic link token** Exchanges a magic link token for a session cookie. Tokens expire after 15 minutes and are single-use. *Auth: Public* *Operation ID: `verifyMagicLink`* **Request body** (`application/json`) *(required)* - `token` (string) *(required)* **Responses** - `200` — Session created - `401` — Token invalid or expired **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/auth/magic-link/verify" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/auth/me {#get-api-v1-auth-me} **Get current user** *Auth: Authenticated (Bearer)* *Operation ID: `getCurrentUser`* **Responses** - `200` — Current user - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/auth/me" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Auth (OAuth 2.1) {#tag-auth-oauth-2-1} OAuth 2.1 authorization-code flow with PKCE for MCP clients (Claude.ai apps and other spec-compliant MCP hosts). Discovery via `.well-known`, Dynamic Client Registration, and short-lived ES256 JWT bearer tokens. ### GET /.well-known/oauth-authorization-server {#get-well-known-oauth-authorization-server} **Authorization-server metadata (RFC 8414)** OAuth 2.1 discovery document. Lists the issuer, authorization/token/registration/jwks endpoints, supported scopes (`mcp.read`, `mcp.write`), supported PKCE methods (`S256`), and supported grant types (`authorization_code`, `refresh_token`). Public — no auth required. *Auth: Public* *Operation ID: `wellKnownAuthorizationServer`* **Responses** - `200` — Authorization-server metadata document **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/.well-known/oauth-authorization-server" ``` ### GET /.well-known/oauth-protected-resource {#get-well-known-oauth-protected-resource} **Protected-resource metadata (RFC 9728)** Discovery document for the MCP resource server. Returned to clients after a `WWW-Authenticate: Bearer resource_metadata=…` challenge so they can locate the authorization server. Public — no authentication required. *Auth: Public* *Operation ID: `wellKnownProtectedResource`* **Responses** - `200` — Resource metadata document **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/.well-known/oauth-protected-resource" ``` ### GET /oauth/authorize {#get-oauth-authorize} **Authorization endpoint (PKCE-only)** Browser-only endpoint. If the user has no session cookie, the server redirects to `/frontend/login.html?return_to=…` and resumes the flow after magic-link verification. Once authenticated, the user is redirected to `/oauth/consent` to approve the requested scopes. Only `response_type=code` and `code_challenge_method=S256` are accepted. *Auth: Public* *Operation ID: `oauthAuthorize`* **Query parameters** - `client_id` (string) *(required)* - `redirect_uri` (string) *(required)* - `response_type` (string) *(required)* - `code_challenge` (string) *(required)* - `code_challenge_method` (string) *(required)* - `state` (string) - `scope` (string) **Responses** - `302` — Redirect to `/login` (no session) or `/oauth/consent` (authenticated). - `400` — Invalid client_id, redirect_uri, or PKCE parameters. **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/oauth/authorize" ``` ### POST /oauth/consent {#post-oauth-consent} **Approve or deny an authorization request** Called by the consent UI (`/oauth/consent` HTML page). Body may be JSON or form-encoded. When `allow=true`, mints a one-shot authorization code (60s TTL, single-use) and returns the redirect URL the browser should follow. When `allow` is anything else, returns the redirect URL with `error=access_denied`. Requires the user's session cookie. *Auth: Public* *Operation ID: `oauthConsent`* **Request body** (`application/json`) *(required)* - `allow` (boolean) — Set to `true` to approve. Anything else (including omitted) is treated as deny. - `client_id` (string) *(required)* - `redirect_uri` (string) *(required)* - `state` (string) - `scope` (string) — example: `"mcp.read mcp.write"` - `code_challenge` (string) *(required)* - `code_challenge_method` (string) *(required)* - `resource` (string) **Responses** - `200` — Redirect URL the browser should navigate to. - `401` — User not logged in. **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/oauth/consent" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /oauth/jwks.json {#get-oauth-jwks-json} **JWKS — public key for verifying access tokens** Returns the public ES256 key used to sign access tokens. Resource servers and agent runtimes can use this to verify JWTs without ever calling back to the auth server. Public — no authentication required. Cached for 5 minutes. *Auth: Public* *Operation ID: `oauthJwks`* **Responses** - `200` — JWKS document **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/oauth/jwks.json" ``` ### POST /oauth/register {#post-oauth-register} **Dynamic Client Registration (RFC 7591)** Registers a new MCP client. Claude.ai uses this automatically when creating a custom app. The `redirect_uris` are validated against an allowlist (currently `https://claude.ai/api/mcp/auth_callback` and the local dashboard for development). Returns a `client_id` (no secret — public clients with PKCE). *Auth: Public* *Operation ID: `oauthRegister`* **Request body** (`application/json`) *(required)* - `redirect_uris` (array) *(required)* - `client_name` (string) — example: `"Claude App"` - `grant_types` (array) — example: `["authorization_code","refresh_token"]` - `response_types` (array) — example: `["code"]` - `token_endpoint_auth_method` (string) — example: `"none"` **Responses** - `201` — Client registered - `400` — redirect_uris missing, malformed, or not on the allowlist ```json { "ok": false, "error": "INVALID_REDIRECT_URI", "message": "redirect_uri is not on the allowlist", "retryable": false } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/oauth/register" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /oauth/token {#post-oauth-token} **Token endpoint** Exchanges an authorization code (with PKCE verifier) for an ES256 JWT access token (1h TTL). The token is signed with the keypair published at `/oauth/jwks.json`, carries the resource server URL as `aud`, and the granted scopes (`mcp.read`, `mcp.write`). A `jti` is persisted server-side to allow revocation. Codes are single-use; re-using a code returns `invalid_grant`. *Auth: Public* *Operation ID: `oauthToken`* **Request body** (`application/json`) *(required)* - `grant_type` (string) *(required)* - `code` (string) *(required)* - `redirect_uri` (string) *(required)* - `client_id` (string) *(required)* - `code_verifier` (string) *(required)* **Responses** - `200` — Access token issued - `400` — Invalid grant — code unknown, expired, already used, PKCE mismatch, or client_id/redirect_uri mismatch. **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/oauth/token" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## API Keys {#tag-api-keys} Create and manage API keys for programmatic access ### POST /api/v1/agent/mcp-link {#post-api-v1-agent-mcp-link} **Build an MCP connection payload for an existing key** Convenience endpoint that returns ready-to-paste MCP connection configs (Cursor, Claude Desktop, stdio CLI) for one of your API keys. Two modes: - **Template** (default): returns the MCP url plus the key's mode/scope and a `` placeholder. The raw key secret is not stored server-side (only its hash), so we cannot re-show the original — paste in the secret you saved at creation time. - **Regenerate** (`{ regenerate: true }`): mints a fresh key with the same name/scope/mode, revokes the old one, and embeds the new raw secret directly. The only path to a truly ready-to-paste config for a key whose original secret was lost. Treat the response as one-time-view material. Session-only (`requireFullAccess`); API-key callers receive 403 INSUFFICIENT_SCOPE. Rate-limited to **10 calls per hour per user** (shared bucket with `POST /api-keys`). *Auth: Authenticated (Bearer)* *Operation ID: `buildMcpLink`* **Request body** (`application/json`) *(required)* - `apiKeyId` (string) *(required)* — ID of the API key to build a config for. Must belong to the caller and not be revoked. - `regenerate` (boolean) — When true, mints a fresh key, revokes the old one, and embeds the real secret in the response. **Responses** - `200` — MCP connection payload (template or regenerate mode) - `400` — Invalid input or key already revoked - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `404` — API key not found or not owned by caller - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/agent/mcp-link" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/api-keys {#get-api-v1-api-keys} **List API keys** *Auth: Authenticated (Bearer)* *Operation ID: `listApiKeys`* **Responses** - `200` — List of API keys - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/api-keys" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/api-keys {#post-api-v1-api-keys} **Create an API key** Creates a new API key. The raw key is returned only once — store it securely. The dashboard / REST layer can never mint admin (`full`) keys; any `scope` value other than `read_only`, `payments`, or `user` is silently downgraded to `user`. Rate-limited to **10 keys per hour per user** (`429 RATE_LIMITED`). *Auth: Authenticated (Bearer)* *Operation ID: `createApiKey`* **Request body** (`application/json`) *(required)* - `name` (string) *(required)* — A label to identify this key - `mode` (string) — Key mode — test keys mint `usdr_test_...` (chain-free), live keys mint `usdr_live_...` (real-chain) - `scope` (string) — Permission scope (hierarchical, higher includes lower): - `read_only` — read-only access (GET endpoints). - `payments` — read + create payment requests / invoices / receipts. - `user` — payments + destructive ops on the user's own data (void, revoke, simulate-pay). `full` is never REST-creatable — it's reserved for session-cookie callers. **Responses** - `201` — Key created - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/api-keys" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/api-keys/{id}/revoke {#post-api-v1-api-keys-id-revoke} **Revoke an API key** Immediately invalidates the key. Any request using it will receive 401. *Auth: Authenticated (Bearer)* *Operation ID: `revokeApiKey`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Key revoked - `400` — Already revoked - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `404` — Key not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/api-keys/{id}/revoke" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/usage/keys {#get-api-v1-usage-keys} **Per-key usage rollup** One row per API key the caller owns. Aggregates `api_usage_log` rows over the requested window. `api_usage_log` only records Bearer-authenticated requests, so cookie-only activity does NOT appear here. Requires the `full` scope (session-cookie callers only). *Auth: Authenticated (Bearer)* *Operation ID: `listApiKeyUsage`* **Query parameters** - `from` (string) — Lower bound (default: 30 days ago) - `to` (string) — Upper bound (default: now) **Responses** - `200` — Per-key usage rows - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/usage/keys" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/usage/summary {#get-api-v1-usage-summary} **Detailed usage breakdown for a single API key** Aggregates `api_usage_log` rows for one API key into totals, per-endpoint counts, and per-day counts. When called without `api_key_id` the totals come back as zeros (the response shape is preserved). Requires the `full` scope (session-cookie callers only). *Auth: Authenticated (Bearer)* *Operation ID: `getUsageSummary`* **Query parameters** - `api_key_id` (string) — Filter to a single API key. Omit to receive the documented empty-totals response. - `from` (string) - `to` (string) **Responses** - `200` — Aggregated usage - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/usage/summary" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Profile {#tag-profile} Business profile and tax settings ### GET /api/v1/profile {#get-api-v1-profile} **Get business profile** *Auth: Authenticated (Bearer)* *Operation ID: `getProfile`* **Responses** - `200` — Profile - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/profile" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PUT /api/v1/profile {#put-api-v1-profile} **Update business profile** *Auth: Authenticated (Bearer)* *Operation ID: `updateProfile`* **Request body** (`application/json`) *(required)* - `business_name` (string) — example: `"Acme Labs"` - `business_address` (string) — example: `"123 Main St"` - `tax_id` (string) — example: `"US-EIN-123456"` - `tax_label` (string) — Label for the tax line on receipts - `tax_rate` (string) — Tax rate as a percentage (0–100) **Responses** - `200` — Profile updated - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X PUT "$USDR_BASE_URL/api/v1/profile" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## Wallets {#tag-wallets} Manage Ethereum wallets ### POST /api/v1/build-send-tx {#post-api-v1-build-send-tx} **Build an unsigned outbound transaction (non-custodial)** Builds the calldata (to, data, value, chainId) and a best-effort gas estimate for sending ETH / USDT / USDC from one of your registered wallets. The caller signs locally with their own tooling and broadcasts via their own RPC — USDReceipt NEVER receives the private key. Use this when you want programmatic outbound transfers (CLI scripts, MCP hosts like Cursor / Claude Desktop, custom integrations) without the in-browser Send modal. There is, by design, no companion endpoint that accepts a signed transaction or a seed phrase: the non-custodial guarantee depends on the key staying in your environment. *Auth: Authenticated (Bearer)* *Operation ID: `buildSendTx`* **Request body** (`application/json`) *(required)* - `from_address` (string) *(required)* — Source wallet — must be a registered wallet on your account. - `to_address` (string) *(required)* — Recipient 0x… address. - `token` (string) *(required)* - `amount` (string) *(required)* — Whole-token amount as a decimal string. '50' for 50 USDC, '0.01' for 0.01 ETH. **Responses** - `200` — Unsigned transaction ready to be signed locally - `400` — Invalid input or build failed - `403` — Source wallet is not registered on the caller's account **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/build-send-tx" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/prices {#get-api-v1-prices} **Spot USD prices for ETH / USDT / USDC** Returns the same CoinGecko-sourced prices the recharge and Send UIs use, cached for 60s. Public — no auth required. Falls back to ETH=0, USDT=1, USDC=1 if the upstream feed is unreachable; callers can detect this by checking ETH === 0 and either retry or hide the USD column. *Auth: Public* *Operation ID: `getPrices`* **Responses** - `200` — USD spot prices **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/prices" ``` ### GET /api/v1/wallets {#get-api-v1-wallets} **List wallets** Returns all wallets. Pass ?include=balance to get live native ETH, USDT, and USDC balances (cached for 60s) plus current USD prices for each asset (CoinGecko, cached for 60s). *Auth: Authenticated (Bearer)* *Operation ID: `listWallets`* **Query parameters** - `include` (string) — Set to 'balance' to include live ETH/USDT/USDC balances per wallet and a top-level `prices` object with USD spot prices. **Responses** - `200` — Wallet list - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/wallets {#post-api-v1-wallets} **Add a wallet** *Auth: Authenticated (Bearer)* *Operation ID: `addWallet`* **Request body** (`application/json`) *(required)* - `address` (string) *(required)* — example: `"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28"` - `label` (string) — example: `"Main wallet"` **Responses** - `201` — Wallet added - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `409` — Wallet already added **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/wallets" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/wallets/generate {#post-api-v1-wallets-generate} **Generate a fresh non-custodial wallet** Mints a brand-new Ethereum keypair on the server, registers the public address against your account, and returns the mnemonic and private key in the response body — exactly once. Nothing about the secret is logged or persisted server-side beyond the public address. **Security notes:** - The response carries `Cache-Control: no-store`. Treat the body as one-time-view material. - If you can run JavaScript locally, prefer client-side generation (`ethers.Wallet.createRandom()`) followed by `POST /wallets` with just the address. That keeps the secret off the wire entirely. - Use this endpoint when you cannot bundle a wallet library client-side (no-code, embedded widgets, headless onboarding, scripted demos). - Rate-limited to 20 requests/hour per API key. Requires `user` scope or higher. *Auth: Authenticated (Bearer)* *Operation ID: `generateWallet`* **Request body** (`application/json`) - `label` (string) — example: `"Fresh checkout wallet"` **Responses** - `201` — Wallet generated and registered. Mnemonic and private key are returned ONCE in the body. - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `429` — Rate limit exceeded (20/hour per API key) - `500` — Wallet was generated but could not be registered. Secret material is still returned in the body so the caller can recover any funds sent to the address. **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/wallets/generate" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/wallets/{id} {#delete-api-v1-wallets-id} **Remove a wallet** Removes a wallet from your account. Any open or `pending_confirmation` payment requests pointing at this wallet's address are **auto-paused** as a side-effect (the buyer-facing pay page shows a "payment paused" notice instead of accepting funds). Past receipts are unaffected. Use `GET /api/v1/wallets/{id}/removal-preview` first to see exactly which payment requests will be paused before committing. *Auth: Authenticated (Bearer)* *Operation ID: `removeWallet`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Wallet removed (with auto-pause side-effect details) - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `404` — Wallet not found **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/wallets/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/wallets/{id}/challenge {#get-api-v1-wallets-id-challenge} **Get verification challenge** Returns a message to sign with your Ethereum wallet to prove ownership. *Auth: Authenticated (Bearer)* *Operation ID: `getWalletChallenge`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Challenge message - `404` — Wallet not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets/{id}/challenge" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/wallets/{id}/removal-preview {#get-api-v1-wallets-id-removal-preview} **Preview wallet removal impact** Returns the list of open / `pending_confirmation` payment requests that would be auto-paused if this wallet were removed right now. Lets agents and dashboards show a "this will pause N checkouts" warning before committing the destructive action. Already-paused or archived rows are excluded. *Auth: Authenticated (Bearer)* *Operation ID: `previewWalletRemoval`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Removal preview - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `404` — Wallet not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets/{id}/removal-preview" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/wallets/{id}/transactions {#get-api-v1-wallets-id-transactions} **Scan on-chain USDT transfers** Queries Ethereum for recent incoming USDT transfers to this wallet. Cross-references against open payment requests. Rate-limited to 10 scans/minute per wallet (`429 RATE_LIMITED`). *Auth: Authenticated (Bearer)* *Operation ID: `scanWalletTransactions`* **Path parameters** - `id` (string) *(required)* **Query parameters** - `blocks` (integer) — How many blocks back to scan **Responses** - `200` — Transfers found - `404` — Wallet not found - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` - `502` — Ethereum RPC error **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets/{id}/transactions" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/wallets/{id}/verify {#post-api-v1-wallets-id-verify} **Verify wallet ownership** Submit the signed challenge message to verify you own this wallet address. *Auth: Authenticated (Bearer)* *Operation ID: `verifyWallet`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `signature` (string) *(required)* **Responses** - `200` — Wallet verified - `400` — Invalid signature - `404` — Wallet not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/wallets/{id}/verify" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## Checkouts {#tag-checkouts} Reusable payment links that accept multiple payments ### POST /api/v1/checkouts {#post-api-v1-checkouts} **Create a checkout** Creates a reusable payment link. Checkouts can accept multiple payments (e.g. for a product page). Supports the `Idempotency-Key` header for safe retries. *Auth: Authenticated (Bearer)* *Operation ID: `createCheckout`* **Header parameters** - `Idempotency-Key` (string) — Optional opt-in replay key for safe retries on mutating endpoints. If the same `Idempotency-Key` is sent twice with identical body+path, the original response is replayed verbatim with `X-Idempotent-Replay: true`. Reusing the key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH. Keys are scoped per user, retained for 24 hours, and must match `[A-Za-z0-9_-]{1,200}`. - `X-Agent-Id` (string) — Stable identifier for the agent making the request. Persisted in the usage log and queryable via `GET /agent-runs/{run_id}`. Up to 64 printable ASCII characters. - `X-Agent-Run-Id` (string) — Per-run identifier. Group all HTTP calls from a single agent run under one id, then fetch the timeline with `GET /agent-runs/{run_id}`. Up to 64 printable ASCII characters. **Request body** (`application/json`) *(required)* - `title` (string) — example: `"Premium Plan"` - `memo` (string) — example: `"Monthly subscription"` - `amount_usdt` (string) *(required)* — example: `"49.99"` - `recipient_address` (string) *(required)* — example: `"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28"` - `collect_buyer_info` (boolean) — Require buyer name/email before payment - `expires_at` (string) — Optional expiration timestamp - `token` (string) — Which stablecoin to accept (default: USDT) - `auto_archive_on_paid` (boolean) — If true, the payment_request is auto-archived as soon as the first receipt is created against it. Used by POS / single-shot kiosk flows. Archived rows are hidden from `GET /payment-requests` unless `include_archived=true`. The receipt itself is unaffected. **Responses** - `201` — Checkout created - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — The supplied `recipient_address` is not a wallet registered to this account. Enforced uniformly for ALL callers (session, OAuth, full-scope API keys, restricted-scope keys, chat agent) — see PAYMENT_LIFECYCLE_API.md's "registered recipient" invariant. Recovery: register the address via `POST /wallets` (or the dashboard's Add Wallet, or chat agent's `generate_wallet`) and retry the create. ```json { "ok": false, "error": "WALLET_NOT_REGISTERED", "message": "Recipient address 0xUnknown… is not a wallet registered to this account. Add it via POST /wallets (or the dashboard's Add Wallet) before creating a checkout or invoice with this recipient.", "recipient_address": "0xUnknown...", "retryable": false, "suggested_action": "POST /wallets to register the address, then retry.", "request_id": "req_..." } ``` - `409` — The `Idempotency-Key` was already used with a different request body within the 24h TTL. ```json { "ok": false, "error": "IDEMPOTENCY_KEY_MISMATCH", "message": "This Idempotency-Key was already used with a different request body.", "retryable": false, "suggested_action": "Pick a different Idempotency-Key for this request body.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/checkouts" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/checkouts/{id}/receipts {#get-api-v1-checkouts-id-receipts} **List checkout receipts** Returns all verified payment receipts for a checkout. *Auth: Authenticated (Bearer)* *Operation ID: `listCheckoutReceipts`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Receipts - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `404` — Checkout not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/checkouts/{id}/receipts" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Invoices {#tag-invoices} One-time invoices with line items ### POST /api/v1/invoices {#post-api-v1-invoices} **Create an invoice** Creates a one-time invoice with line items. The total is calculated from the line items plus any tax from your profile. Supports the `Idempotency-Key` header for safe retries. *Auth: Authenticated (Bearer)* *Operation ID: `createInvoice`* **Header parameters** - `Idempotency-Key` (string) — Optional opt-in replay key for safe retries on mutating endpoints. If the same `Idempotency-Key` is sent twice with identical body+path, the original response is replayed verbatim with `X-Idempotent-Replay: true`. Reusing the key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH. Keys are scoped per user, retained for 24 hours, and must match `[A-Za-z0-9_-]{1,200}`. - `X-Agent-Id` (string) — Stable identifier for the agent making the request. Persisted in the usage log and queryable via `GET /agent-runs/{run_id}`. Up to 64 printable ASCII characters. - `X-Agent-Run-Id` (string) — Per-run identifier. Group all HTTP calls from a single agent run under one id, then fetch the timeline with `GET /agent-runs/{run_id}`. Up to 64 printable ASCII characters. **Request body** (`application/json`) *(required)* - `title` (string) — example: `"Smart contract audit"` - `memo` (string) — example: `"Net 30"` - `recipient_address` (string) *(required)* — example: `"0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28"` - `client_name` (string) — example: `"Acme DAO"` - `client_email` (string) — example: `"billing@acme.com"` - `due_date` (string) — example: `"2026-06-15"` - `line_items` (array) *(required)* - `token` (string) — Which stablecoin to accept (default: USDT) **Responses** - `201` — Invoice created - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — The supplied `recipient_address` is not a wallet registered to this account. Enforced uniformly for ALL callers (session, OAuth, full-scope API keys, restricted-scope keys, chat agent) — see PAYMENT_LIFECYCLE_API.md's "registered recipient" invariant. Recovery: register the address via `POST /wallets` (or the dashboard's Add Wallet, or chat agent's `generate_wallet`) and retry the create. ```json { "ok": false, "error": "WALLET_NOT_REGISTERED", "message": "Recipient address 0xUnknown… is not a wallet registered to this account. Add it via POST /wallets (or the dashboard's Add Wallet) before creating a checkout or invoice with this recipient.", "recipient_address": "0xUnknown...", "retryable": false, "suggested_action": "POST /wallets to register the address, then retry.", "request_id": "req_..." } ``` - `409` — The `Idempotency-Key` was already used with a different request body within the 24h TTL. ```json { "ok": false, "error": "IDEMPOTENCY_KEY_MISMATCH", "message": "This Idempotency-Key was already used with a different request body.", "retryable": false, "suggested_action": "Pick a different Idempotency-Key for this request body.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/invoices" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## Payment Requests {#tag-payment-requests} Unified view of checkouts and invoices ### GET /api/v1/payment-requests {#get-api-v1-payment-requests} **List payment requests** Returns all checkouts and invoices for the authenticated user. *Auth: Authenticated (Bearer)* *Operation ID: `listPaymentRequests`* **Query parameters** - `include_archived` (boolean) — Include payment requests that have been auto-archived (e.g. POS one-shot checkouts after payment). Archived rows are hidden by default to keep the list tidy. **Responses** - `200` — Payment requests - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/payment-requests" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/payment-requests/{id} {#get-api-v1-payment-requests-id} **Get a payment request** Public endpoint — no authentication required. Returns payment request details including line items for invoices. *Auth: Public* *Operation ID: `getPaymentRequest`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Payment request - `404` — Not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/payment-requests/{id}" ``` ### POST /api/v1/payment-requests/{id}/arm-watch {#post-api-v1-payment-requests-id-arm-watch} **Arm the focused scanner for a PR's recipient wallet** Tells the server to actively poll Etherscan for inbound transfers to this PR's recipient wallet for the next 10 minutes. Used by the buyer's checkout page when a decentralized wallet provider is detected (EIP-1193 / EIP-6963), so any inbound matching transfer is reconciled within ~5–15s of mining — independent of whether the browser ever returns with the tx hash. Idempotent: re-calling refreshes the deadline (longer wins, shorter is ignored). Silent no-op if the recipient address isn't in the seller's wallets table (`armed: false, reason: "recipient_not_registered"`) — surface that to the seller; the /verify path still works for the buyer. Public, rate-limited identically to /verify. *Auth: Public* *Operation ID: `armPaymentWatch`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Arm result - `404` — Payment request not found - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/arm-watch" ``` ### GET /api/v1/payment-requests/{id}/events {#get-api-v1-payment-requests-id-events} **Live SSE stream for a payment request** Server-Sent Events stream covering the full verification lifecycle for any payment request (checkout OR invoice). Returns `text/event-stream`. Sequence: 1. `snapshot` — emitted immediately on connect with current state 2. `tx_seen` — first observation of a matching inbound transfer (pre-confirmation) 3. `verified` — receipt created (terminal) Server emits `:\n\n` keep-alive comments every 25s so proxies don't drop the connection. Stream auto-closes on `verified` or 30 min idle. Event shapes: - `{ type: "snapshot", payment_request_id, payment_request_type, status, amount_required_usdt, token, receipt_id?, tx_hash? }` - `{ type: "tx_seen", tx_hash, block_number, amount_usdt, token, from_address }` - `{ type: "verified", receipt_id, receipt_number, tx_hash, amount_usdt, token, from_address }` *Auth: Public* *Operation ID: `paymentRequestEventsStream`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Event stream - `404` — Payment request not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/payment-requests/{id}/events" ``` ### POST /api/v1/payment-requests/{id}/expected-sender {#post-api-v1-payment-requests-id-expected-sender} **Bind the expected sender address for strict reconciliation** When a buyer connects their wallet on the checkout page (Pay with Wallet flow), the dapp POSTs the connected EVM account here BEFORE broadcasting the transaction. After this binding is in place, both /verify and the wallet watcher refuse to settle this payment request with any transfer that doesn't originate from `from_address`. Public, rate-limited identically to /verify. Re-POSTing overrides the previous binding (lock-free; if two buyers race the same checkout, the last one to click Pay wins the binding — the loser's tx lands in the Unmatched inbox for manual reconciliation). Refused if the payment request is no longer `open`. *Auth: Public* *Operation ID: `setExpectedSender`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `from_address` (string) *(required)* — The buyer's connected EVM account that will broadcast the payment tx. 0x + 40 hex chars. **Responses** - `200` — Binding set - `400` — Invalid input or payment request not open - `404` — Payment request not found - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/expected-sender" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/payment-requests/{id}/pause {#post-api-v1-payment-requests-id-pause} **Pause an open payment request** Temporarily disables an OPEN payment request so the buyer-facing `/pay/:id` page rejects new payments and shows a 'paused' message. Reversible via `/resume`. Only requests with status `open` can be paused — paid, void, expired, or pending requests are rejected with `400`. Same `LIVE_TEST_MODE_MISMATCH` cross-mode guard applies as for `/void`. *Auth: Authenticated (Bearer)* *Operation ID: `pausePaymentRequest`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Paused - `400` — Cannot pause (already paused or wrong status) - `403` — Not your request / wrong scope / test-live mode mismatch - `404` — Not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/pause" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/payment-requests/{id}/recover {#post-api-v1-payment-requests-id-recover} **Seller-side recovery for a stuck payment** For when a buyer paid but the receipt didn't materialize (e.g. the buyer broadcast a tx but never came back to the checkout page; the focused scanner missed it; the chain-wide watcher is disabled). Two modes: **With `tx_hash`** — synchronously verifies the tx on-chain and creates the receipt. Returns the unified status snapshot (same shape as `/verify` and `/status`). **Without `tx_hash`** — arms the focused Etherscan scanner on the PR's recipient for 15 minutes. The scanner picks up any matching inbound transfer within ~5–15s of detection and creates the receipt then. Auth: caller must be the PR's seller (session cookie or API key with `payments` scope). Distinct from `/verify`'s public PR-id-as-bearer model. *Auth: Authenticated (Bearer)* *Operation ID: `recoverPayment`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) - `tx_hash` (string) — Optional. If provided, the server verifies this specific tx synchronously. **Responses** - `200` — Either a scan-armed acknowledgement (no tx_hash) or the unified status envelope (with tx_hash). - `400` — Invalid tx_hash format - `404` — Payment request not found (or caller is not the seller) - `409` — Payment request is paused **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/recover" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/payment-requests/{id}/resume {#post-api-v1-payment-requests-id-resume} **Resume a paused payment request** Re-enables a previously paused payment request so it can accept payments again. *Auth: Authenticated (Bearer)* *Operation ID: `resumePaymentRequest`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Resumed - `400` — Not paused - `403` — Not your request / wrong scope / test-live mode mismatch - `404` — Not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/resume" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/payment-requests/{id}/status {#get-api-v1-payment-requests-id-status} **Get current verification status (read-only)** Pure read endpoint for the payment lifecycle state machine. Unlike `/verify`, this: - Has **no side effects** (creates no rows, mutates nothing). Safe to poll. - Returns a clean `status` enum (`awaiting_payment` | `tx_seen` | `confirming` | `verified` | `failed` | `expired` | `void`) instead of error-shaped responses for in-progress states. - Reports `tx_seen` if the watcher has already observed a matching inbound transfer. Pass `?tx_hash=…` if the buyer just broadcast a tx — the server actively checks it on-chain and returns `confirmations_seen` vs `confirmations_required` so you can render a real progress bar. Without it, the response reflects only what the watcher has observed. **Auth: public.** Same threat model as `/verify` — PR ids are 96-bit random and act as bearer. Rate-limited identically to `/verify`. See PAYMENT_LIFECYCLE_API.md for the full state machine. *Auth: Public* *Operation ID: `getPaymentStatus`* **Path parameters** - `id` (string) *(required)* **Query parameters** - `tx_hash` (string) — Optional. If present, server actively checks this tx hash on-chain and reports confirmations_seen. **Responses** - `200` — Current status - `400` — Invalid tx_hash format - `404` — Payment request not found - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/payment-requests/{id}/status" ``` ### POST /api/v1/payment-requests/{id}/verify {#post-api-v1-payment-requests-id-verify} **Verify a payment + create the receipt** Buyer-side write path. Submit a tx hash; the server verifies it on-chain (USDT or USDC, matching the token selected at creation) and creates the receipt as a side effect. Accepts payments within 1% of the requested amount. Returns the **same response shape as `/status`** — a unified envelope with a clean `status` enum. HTTP 200 for any chain-level outcome (including in-progress and failed states); HTTP 4xx only for input validation. In-progress states (`confirming` / `awaiting_payment`) return 200 + status, not 202 + error. Side effects on `status: "verified"`: creates the receipt row, marks the PR `paid`, fires the configured webhooks, sends the buyer email (when buyer info is present). Rate-limited to 30 verifies/minute per payment request (`429 RATE_LIMITED`). See PAYMENT_LIFECYCLE_API.md for the full state machine + `failure_reason` enum. *Auth: Public* *Operation ID: `verifyPayment`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `tx_hash` (string) *(required)* — Ethereum transaction hash (0x + 64 hex chars) - `client_name` (string) — Buyer name (required if checkout has collect_buyer_info enabled) - `client_email` (string) — Buyer email (required if checkout has collect_buyer_info enabled) **Responses** - `200` — Status snapshot — any chain-level outcome (verified, confirming, awaiting_payment, failed). Receipt is materialized when status='verified'. - `400` — Input validation error (missing/malformed tx_hash, missing buyer info) - `404` — Payment request not found - `409` — Payment request is paused (resume it and retry) - `429` — Too many requests for this user/key/endpoint within the rolling window. The `Retry-After` response header (seconds) and `retry_after_ms` body field both tell you how long to wait. ```json { "ok": false, "error": "RATE_LIMITED", "message": "Too many requests. Try again in 42s.", "retryable": true, "retry_after_ms": 42000, "suggested_action": "Wait for retry_after_ms before retrying.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/verify" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/payment-requests/{id}/void {#post-api-v1-payment-requests-id-void} **Void a payment request** Cancels an open payment request. Cannot void a paid request. When called with an API key, the key's mode (`usdr_test_…` vs `usdr_live_…`) must match the payment request's `test_mode` or the request is rejected with `403 LIVE_TEST_MODE_MISMATCH`. Session-cookie callers can void either mode. *Auth: Authenticated (Bearer)* *Operation ID: `voidPaymentRequest`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Voided - `400` — Cannot void (already paid) - `403` — Not your payment request, missing scope, or test/live mode mismatch - `404` — Not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/void" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Receipts {#tag-receipts} On-chain verified payment receipts ### GET /api/v1/receipts {#get-api-v1-receipts} **List receipts** Returns all verified receipts for the authenticated user. *Auth: Authenticated (Bearer)* *Operation ID: `listReceipts`* **Responses** - `200` — Receipts - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/receipts" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/receipts/export/csv {#get-api-v1-receipts-export-csv} **Export receipts as CSV** Downloads all receipts as a CSV file for accounting/compliance. *Auth: Authenticated (Bearer)* *Operation ID: `exportReceiptsCsv`* **Responses** - `200` — CSV file - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/receipts/export/csv" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/receipts/{id} {#get-api-v1-receipts-id} **Get a receipt** Public endpoint — returns a verified on-chain receipt. *Auth: Public* *Operation ID: `getReceipt`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Receipt - `404` — Not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/receipts/{id}" ``` ## Catalog {#tag-catalog} Reusable product/service catalog items used by checkouts and invoices ### GET /api/v1/catalog {#get-api-v1-catalog} **List catalog items** Returns the user's reusable catalog items. Items can be referenced by ID when creating checkouts/invoices so prices/descriptions stay in sync. Requires `read_only` scope. *Auth: Authenticated (Bearer)* *Operation ID: `listCatalogItems`* **Query parameters** - `active` (boolean) — If true, only return active items **Responses** - `200` — Items - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/catalog" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/catalog {#post-api-v1-catalog} **Create a catalog item** Creates a reusable product/service. Requires `user` scope. *Auth: Authenticated (Bearer)* *Operation ID: `createCatalogItem`* **Request body** (`application/json`) *(required)* - `name` (string) *(required)* — example: `"Smart contract audit"` - `description` (string) - `sku` (string) — example: `"AUDIT-1H"` - `unit_price` (string) *(required)* — example: `"150.00"` - `currency` (string) - `is_active` (boolean) **Responses** - `201` — Created - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/catalog" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/catalog/{id} {#get-api-v1-catalog-id} **Get a catalog item** *Auth: Authenticated (Bearer)* *Operation ID: `getCatalogItem`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Item - `404` — Not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/catalog/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PATCH /api/v1/catalog/{id} {#patch-api-v1-catalog-id} **Update a catalog item** *Auth: Authenticated (Bearer)* *Operation ID: `updateCatalogItem`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `name` (string) - `description` (string) - `sku` (string) - `unit_price` (string) - `currency` (string) - `is_active` (boolean) **Responses** - `200` — Updated - `404` — Not found **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/catalog/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/catalog/{id} {#delete-api-v1-catalog-id} **Delete a catalog item** *Auth: Authenticated (Bearer)* *Operation ID: `deleteCatalogItem`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Deleted - `404` — Not found **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/catalog/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Compliance {#tag-compliance} Per-jurisdiction compliance profiles (VAT/IVA/MwSt/etc.) and reports ### GET /api/v1/compliance-profiles {#get-api-v1-compliance-profiles} **List compliance profiles** Returns all compliance profiles for the user. A profile bundles tax label, default rate, invoice prefix, fiscal year start, and language for a jurisdiction. *Auth: Authenticated (Bearer)* *Operation ID: `listComplianceProfiles`* **Responses** - `200` — Profiles **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/compliance-profiles" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/compliance-profiles {#post-api-v1-compliance-profiles} **Create a compliance profile (or clone from a template)** Pass `template_id` to clone a built-in template (e.g. `IT-iva`, `DE-mwst`, `UK-vat`, `FR-tva`, `ES-iva`, `NL-btw`, `US-default`). Without `template_id`, supply individual fields (`name` + `jurisdiction` are required). *Auth: Authenticated (Bearer)* *Operation ID: `createComplianceProfile`* **Request body** (`application/json`) *(required)* - `template_id` (string) — Built-in template ID. When set, other fields override the template defaults. - `name` (string) - `jurisdiction` (string) — example: `"IT"` - `default_tax_label` (string) — example: `"IVA"` - `default_tax_rate` (string) — example: `"22"` - `invoice_prefix` (string) — example: `"IT"` - `invoice_number_format` (string) — example: `"{prefix}-{year}-{seq}"` - `fiscal_year_start_month` (integer) - `language` (string) — example: `"it"` - `receipt_footer` (string) - `buyer_kyc_threshold` (string) - `reverse_charge_for_eu_b2b` (boolean) - `set_as_default` (boolean) **Responses** - `201` — Created **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/compliance-profiles" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/compliance-profiles/templates {#get-api-v1-compliance-profiles-templates} **List built-in compliance templates** Returns the seed templates (US, UK, IT, DE, FR, ES, NL) available for cloning. *Auth: Authenticated (Bearer)* *Operation ID: `listComplianceTemplates`* **Responses** - `200` — Templates **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/compliance-profiles/templates" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/compliance-profiles/{id} {#get-api-v1-compliance-profiles-id} **Get a compliance profile** *Auth: Authenticated (Bearer)* *Operation ID: `getComplianceProfile`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Profile - `404` — Not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/compliance-profiles/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PATCH /api/v1/compliance-profiles/{id} {#patch-api-v1-compliance-profiles-id} **Update a compliance profile** *Auth: Authenticated (Bearer)* *Operation ID: `updateComplianceProfile`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `name` (string) - `jurisdiction` (string) - `default_tax_label` (string) - `default_tax_rate` (string) - `invoice_prefix` (string) - `invoice_number_format` (string) - `fiscal_year_start_month` (integer) - `language` (string) - `receipt_footer` (string) - `buyer_kyc_threshold` (string) - `reverse_charge_for_eu_b2b` (boolean) - `set_as_default` (boolean) **Responses** - `200` — Updated - `404` — Not found **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/compliance-profiles/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/compliance-profiles/{id} {#delete-api-v1-compliance-profiles-id} **Delete a compliance profile** *Auth: Authenticated (Bearer)* *Operation ID: `deleteComplianceProfile`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Deleted - `404` — Not found **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/compliance-profiles/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/compliance-profiles/{id}/vat-report {#get-api-v1-compliance-profiles-id-vat-report} **Generate a VAT/IVA report** Aggregates settled receipts attached to this compliance profile within a date range. Excludes simulated (test-mode) receipts unless `include_simulated=true`. *Auth: Authenticated (Bearer)* *Operation ID: `vatReport`* **Path parameters** - `id` (string) *(required)* **Query parameters** - `from` (string) - `to` (string) - `include_simulated` (boolean) **Responses** - `200` — Report **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/compliance-profiles/{id}/vat-report" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Webhooks {#tag-webhooks} Outbound webhook endpoints (HTTP, email, Slack, Discord) and delivery logs ### GET /api/v1/webhooks {#get-api-v1-webhooks} **List webhook endpoints** *Auth: Authenticated (Bearer)* *Operation ID: `listWebhooks`* **Responses** - `200` — Endpoints **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/webhooks" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/webhooks {#post-api-v1-webhooks} **Create a webhook endpoint** Creates a new outbound delivery target. Returns the signing secret ONCE — store it now; it cannot be retrieved later. **Signature header (HTTP deliveries):** `X-USDR-Signature` carries a comma-separated list of fields: ``` X-USDR-Signature: t=1700000000,v1=,sha256= ``` - `t` — unix seconds when the signature was generated. Subscribers should reject deliveries whose `t` is more than 5 minutes off from their own clock to prevent replay. - `v1` — HMAC-SHA256 of `${t}.${body}` keyed with the endpoint secret. **This is the field new integrations should verify.** - `sha256` — legacy HMAC-SHA256 of `body` only (no timestamp). Emitted during the transition window so existing subscribers keep verifying; will be removed in a future release. **URL safety:** webhook URLs that resolve to private/reserved IP ranges (loopback, RFC-1918, link-local, cloud-metadata) are rejected at registration AND re-checked before every delivery. `http://localhost`, `http://169.254.169.254`, and similar will return `400 Invalid url`. *Auth: Authenticated (Bearer)* *Operation ID: `createWebhook`* **Request body** (`application/json`) *(required)* - `type` (string) *(required)* - `url` (string) — Required for type=http, slack, discord - `email` (string) — Required for type=email - `events` (array) *(required)* — Use ["*"] to subscribe to all events - `description` (string) **Responses** - `201` — Created — secret returned ONCE **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/webhooks" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### PATCH /api/v1/webhooks/{id} {#patch-api-v1-webhooks-id} **Update a webhook endpoint** *Auth: Authenticated (Bearer)* *Operation ID: `updateWebhook`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `url` (string) - `email` (string) - `events` (array) - `description` (string) - `is_active` (boolean) **Responses** - `200` — Updated - `404` — Not found **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/webhooks/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/webhooks/{id} {#delete-api-v1-webhooks-id} **Delete a webhook endpoint** *Auth: Authenticated (Bearer)* *Operation ID: `deleteWebhook`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Deleted - `404` — Not found **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/webhooks/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/webhooks/{id}/deliveries {#get-api-v1-webhooks-id-deliveries} **List recent deliveries for an endpoint** *Auth: Authenticated (Bearer)* *Operation ID: `listWebhookDeliveries`* **Path parameters** - `id` (string) *(required)* **Query parameters** - `limit` (integer) **Responses** - `200` — Deliveries **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/webhooks/{id}/deliveries" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/webhooks/{id}/test {#post-api-v1-webhooks-id-test} **Send a test ping** Fires a `webhook.test` payload immediately so you can verify the endpoint receives and validates correctly. *Auth: Authenticated (Bearer)* *Operation ID: `testWebhook`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Queued/sent **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/webhooks/{id}/test" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Recurring Invoices {#tag-recurring-invoices} Recurring invoice templates (weekly/monthly/quarterly/annual) with auto-generation and chasing ### GET /api/v1/recurring-invoices {#get-api-v1-recurring-invoices} **List recurring invoice templates** *Auth: Authenticated (Bearer)* *Operation ID: `listRecurringInvoices`* **Responses** - `200` — Templates **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/recurring-invoices" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/recurring-invoices {#post-api-v1-recurring-invoices} **Create a recurring invoice template** An hourly cron walks active templates and generates new invoices when `next_run_at` is reached. A daily cron chases overdue invoices. *Auth: Authenticated (Bearer)* *Operation ID: `createRecurringInvoice`* **Request body** (`application/json`) *(required)* - `title` (string) - `client_name` (string) - `client_email` (string) *(required)* - `recipient_address` (string) *(required)* - `cadence` (string) *(required)* - `start_date` (string) - `end_date` (string) - `net_terms_days` (integer) - `auto_send_email` (boolean) - `auto_chase` (boolean) - `token` (string) - `tax_label` (string) - `tax_rate` (string) - `line_items` (array) *(required)* **Responses** - `201` — Created **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/recurring-invoices" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### PATCH /api/v1/recurring-invoices/{id} {#patch-api-v1-recurring-invoices-id} **Update a recurring invoice template (incl. pause/resume via is_active)** *Auth: Authenticated (Bearer)* *Operation ID: `updateRecurringInvoice`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `is_active` (boolean) - `cadence` (string) - `next_run_at` (string) - `end_date` (string) - `net_terms_days` (integer) - `auto_send_email` (boolean) - `auto_chase` (boolean) - `tax_label` (string) - `tax_rate` (string) - `line_items` (array) **Responses** - `200` — Updated **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/recurring-invoices/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/recurring-invoices/{id} {#delete-api-v1-recurring-invoices-id} **Delete a recurring invoice template** *Auth: Authenticated (Bearer)* *Operation ID: `deleteRecurringInvoice`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Deleted **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/recurring-invoices/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/recurring-invoices/{id}/pause {#post-api-v1-recurring-invoices-id-pause} **Pause a recurring invoice template** Sets `is_active = false`. The cron will skip this template until it is resumed. *Auth: Authenticated (Bearer)* *Operation ID: `pauseRecurringInvoice`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Paused **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/recurring-invoices/{id}/pause" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/recurring-invoices/{id}/resume {#post-api-v1-recurring-invoices-id-resume} **Resume a paused recurring invoice template** Sets `is_active = true` so the cron resumes generating invoices on the next tick. *Auth: Authenticated (Bearer)* *Operation ID: `resumeRecurringInvoice`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Resumed **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/recurring-invoices/{id}/resume" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/recurring-invoices/{id}/run-now {#post-api-v1-recurring-invoices-id-run-now} **Generate this template's invoice immediately** Forces a generation cycle for a single template (out of band). Updates `next_run_at` per the cadence. *Auth: Authenticated (Bearer)* *Operation ID: `runRecurringInvoiceNow`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Generated **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/recurring-invoices/{id}/run-now" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Wallet Watcher {#tag-wallet-watcher} Background Etherscan polling for unmatched incoming transfers ### GET /api/v1/wallets/unmatched {#get-api-v1-wallets-unmatched} **List all unmatched incoming transfers across wallets** The background watcher polls Etherscan every minute and adds incoming USDT/USDC transfers that don't auto-match an open checkout/invoice to this inbox. *Auth: Authenticated (Bearer)* *Operation ID: `listAllUnmatched`* **Responses** - `200` — Unmatched transfers **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets/unmatched" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/wallets/{id}/unmatched {#get-api-v1-wallets-id-unmatched} **List unmatched transfers for a wallet** *Auth: Authenticated (Bearer)* *Operation ID: `listWalletUnmatched`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Transfers **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/wallets/{id}/unmatched" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/wallets/{id}/unmatched/{transferId}/attach {#post-api-v1-wallets-id-unmatched-transferid-attach} **Issue a receipt for an unmatched inbound transfer** Two modes share this endpoint: 1. **Attach to PR** — supply `payment_request_id`. The transfer is linked to the chosen checkout/invoice and the PR flips to `paid` once cumulative receipts cover the amount. 2. **Standalone** — omit `payment_request_id`. A receipt is issued with `source='wallet_transfer'` and no parent PR. Use this when the wallet has no active checkout/invoice for the inbound payment (e.g. a freshly-added wallet whose owner never used USDReceipt for sales). Optional `client_name`, `client_email`, `title`, `memo` are recorded directly on the receipt. In both modes the inbox row flips to `status='attached'` and the resulting receipt is returned. *Auth: Authenticated (Bearer)* *Operation ID: `attachUnmatchedTransfer`* **Path parameters** - `id` (string) *(required)* - `transferId` (string) *(required)* **Request body** (`application/json`) - `payment_request_id` (string) — Optional. Omit for a standalone wallet-transfer receipt. - `client_name` (string) — Optional. Payer name (standalone receipts only). - `client_email` (string) — Optional. Payer email (standalone receipts only). - `title` (string) — Optional. Short description (standalone receipts only). - `memo` (string) — Optional. Free-form memo (standalone receipts only). **Responses** - `200` — Receipt issued **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/wallets/{id}/unmatched/{transferId}/attach" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/wallets/{id}/unmatched/{transferId}/ignore {#post-api-v1-wallets-id-unmatched-transferid-ignore} **Mark an unmatched transfer as ignored** *Auth: Authenticated (Bearer)* *Operation ID: `ignoreUnmatchedTransfer`* **Path parameters** - `id` (string) *(required)* - `transferId` (string) *(required)* **Responses** - `200` — Ignored **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/wallets/{id}/unmatched/{transferId}/ignore" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PATCH /api/v1/wallets/{id}/watching {#patch-api-v1-wallets-id-watching} **Enable/disable background watching for a wallet** *Auth: Authenticated (Bearer)* *Operation ID: `toggleWalletWatching`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `watching_enabled` (boolean) *(required)* **Responses** - `200` — Updated **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/wallets/{id}/watching" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## POS Terminal {#tag-pos-terminal} Server-Sent Events stream for live point-of-sale terminals ### GET /api/v1/checkouts/{id}/events {#get-api-v1-checkouts-id-events} **Live SSE stream for a checkout (POS terminal)** **Back-compat alias for `/api/v1/payment-requests/{id}/events`.** Prefer the canonical path for new integrations — it works for invoices too and uses consistent error codes (`PAYMENT_REQUEST_NOT_FOUND` vs the legacy `CHECKOUT_NOT_FOUND`). Same protocol: Returns `text/event-stream`. Initial `snapshot` event reports current status, then `tx_seen` (matching transfer detected) and `verified` (receipt created) events. Event shapes: - `{ type: "snapshot", status, receipt_id }` - `{ type: "tx_seen", block_number, tx_hash, from_address, amount_usdt, token }` - `{ type: "verified", receipt_id, receipt_number, tx_hash, amount_usdt, token, from_address }` *Auth: Public* *Operation ID: `checkoutEventsStream`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Event stream - `404` — Checkout not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/checkouts/{id}/events" ``` ## Test Mode {#tag-test-mode} Test-mode tools — chain-free payment simulator for end-to-end integration testing ### POST /api/v1/payment-requests/{id}/simulate-pay {#post-api-v1-payment-requests-id-simulate-pay} **Simulate a buyer payment (test mode only)** Generates a real receipt + fires every webhook/email/POS-terminal SSE event identically to a real on-chain payment, but with a synthetic transaction hash. **Hard-rejects on live-mode payment requests** with `LIVE_MODE_NO_SIMULATION`. When called with an API key, the key itself must be in test mode (`usdr_test_…`). When called from a session, the payment request itself must be `test_mode = 1`. Use this to: - Test webhook integrations end-to-end without spending gas - Demo POS terminal flows - Develop AI agents and verify the receipt/PDF/email path Receipts created via the simulator have `simulated = 1`, carry a SIMULATED pill in the UI, watermark the PDF, and are excluded from VAT reports unless `include_simulated=true`. *Auth: Authenticated (Bearer)* *Operation ID: `simulatePayment`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) - `from_address` (string) — Optional buyer address (40-char hex). Defaults to a recognisable simulated address. - `amount_override_usdt` (string) — Optional override (e.g. for partial-pay testing). - `client_name` (string) - `client_email` (string) **Responses** - `201` — Simulated payment recorded - `403` — Live-mode payment request, or API key mode does not match the payment request mode - `404` — Payment request not found - `409` — Request is already paid, void, or expired **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/payment-requests/{id}/simulate-pay" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ## Agent Ops {#tag-agent-ops} Endpoints designed for autonomous agents — run-trace queries, MCP/API key monitoring, and idempotent retries ### GET /api/v1/agent-runs/{run_id} {#get-api-v1-agent-runs-run-id} **Trace every API call from a single agent run** Returns the chronological HTTP timeline for the requested `run_id` (the value previously sent in `X-Agent-Run-Id`). Scoped to the calling user. Capped at 500 rows; older windows return `truncated: true`. Use this after an autonomous agent run completes (or fails) to reconstruct exactly what it did. *Auth: Authenticated (Bearer)* *Operation ID: `getAgentRun`* **Path parameters** - `run_id` (string) *(required)* **Responses** - `200` — Agent run trace - `400` — Invalid run id format ```json { "ok": false, "error": "INVALID_RUN_ID", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — API key lacks the scope required for this endpoint. Hierarchy is read_only < payments < user < full. ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Requires scope 'user' or higher", "retryable": false, "suggested_action": "Use an API key with scope 'user' or higher.", "required_scope": "user", "current_scope": "payments", "request_id": "req_..." } ``` - `404` — No usage rows found for this run id ```json { "ok": false, "error": "AGENT_RUN_NOT_FOUND", "run_id": "...", "retryable": false, "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/agent-runs/{run_id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Organizations {#tag-organizations} Org / membership / invite CRUD + audit log. See the [Organizations & roles](https://usdreceipt.xyz/docs.md#organizations--roles) section of the docs for the role matrix and onboarding flows. ### GET /api/v1/invites/{token} {#get-api-v1-invites-token} **Public invite preview (no auth)** Returns the org name, inviter email, role, expiry, and status for the given token. Used by the accept-invite landing page. Expired/revoked invites are still returned with the appropriate status — the landing page renders contextual messaging. *Auth: Public* *Operation ID: `previewInvite`* **Path parameters** - `token` (string) *(required)* **Responses** - `200` — Invite preview - `404` — Invite not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/invites/{token}" ``` ### POST /api/v1/invites/{token}/accept {#post-api-v1-invites-token-accept} **Accept an invite (authed)** Caller must be signed in as the invited email. Joins the org with the assigned role and returns the new membership row. Idempotent — re-accepting from the same user returns the existing membership. *Auth: Authenticated (Bearer)* *Operation ID: `acceptInvite`* **Path parameters** - `token` (string) *(required)* **Responses** - `200` — Accepted - `400` — Invite expired or already resolved - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Email mismatch — invite was sent to a different address - `404` — Invite not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/invites/{token}/accept" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/invites/{token}/decline {#post-api-v1-invites-token-decline} **Decline an invite (authed)** Marks the invite revoked with the declining user's id as the revoker. Distinct from admin-revoke in the audit log. *Auth: Authenticated (Bearer)* *Operation ID: `declineInvite`* **Path parameters** - `token` (string) *(required)* **Responses** - `200` — Declined - `400` — Invite already resolved - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `404` — Invite not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/invites/{token}/decline" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/me/invites {#get-api-v1-me-invites} **List my pending invites** Returns all pending invites addressed to the caller's email across every org, decorated with org_name and inviter_email so the dashboard banner can render without extra lookups. *Auth: Authenticated (Bearer)* *Operation ID: `listMyInvites`* **Responses** - `200` — Pending invites for the caller - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/me/invites" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/me/invites/{inviteId}/accept {#post-api-v1-me-invites-inviteid-accept} **Accept invite by id (authed)** Same effect as `POST /invites/:token/accept` but addressed by invite id (used by the dashboard's pending-invites banner). *Auth: Authenticated (Bearer)* *Operation ID: `acceptMyInvite`* **Path parameters** - `inviteId` (string) *(required)* **Responses** - `200` — Accepted - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Email mismatch - `404` — Invite not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/me/invites/{inviteId}/accept" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/me/invites/{inviteId}/decline {#post-api-v1-me-invites-inviteid-decline} **Decline invite by id (authed)** *Auth: Authenticated (Bearer)* *Operation ID: `declineMyInvite`* **Path parameters** - `inviteId` (string) *(required)* **Responses** - `200` — Declined - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `404` — Invite not found **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/me/invites/{inviteId}/decline" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/orgs {#get-api-v1-orgs} **List orgs the caller is a member of** Returns all non-archived orgs the caller belongs to, with their role in each. Includes `active_org_id` so the dashboard can highlight the current context. Ordered by `last_seen_at` DESC (most-recently-used first), falling back to `created_at` DESC. See the [Organizations & roles](#organizations--roles) section for the full model. *Auth: Authenticated (Bearer)* *Operation ID: `listOrgs`* **Responses** - `200` — Org list - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/orgs {#post-api-v1-orgs} **Create a new organization** Creates a non-personal org with the caller as Owner. The slug is auto-generated from the name (lowercase ASCII with a short id suffix for uniqueness). To create a personal org explicitly (rare — only invite-driven users without one already), see the service-layer docs. *Auth: Authenticated (Bearer)* *Operation ID: `createOrg`* **Request body** (`application/json`) *(required)* - `name` (string) *(required)* — example: `"Acme Inc"` **Responses** - `201` — Org created; caller is Owner - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/orgs" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### GET /api/v1/orgs/current {#get-api-v1-orgs-current} **Get the active org + caller's role in it** Returns the org the current session/key is acting against, plus the caller's role. Returns `{ org: null, role: null }` when the caller has no memberships (only possible for orphaned legacy accounts). *Auth: Authenticated (Bearer)* *Operation ID: `getCurrentOrg`* **Responses** - `200` — Current org + role - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs/current" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/orgs/{id} {#get-api-v1-orgs-id} **Get org details** Member-only. Returns the full org row plus the caller's role. *Auth: Authenticated (Bearer)* *Operation ID: `getOrg`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Org details - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — Org not found **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PATCH /api/v1/orgs/{id} {#patch-api-v1-orgs-id} **Update org settings (admin+)** Updates name, business info, and defaults. Slug is read-only after creation. Personal orgs accept the same fields but cannot be archived. *Auth: Authenticated (Bearer)* *Operation ID: `updateOrg`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `name` (string) - `business_name` (string) - `business_address` (string) - `tax_id` (string) - `default_token` (string) - `default_compliance_profile_id` (string) **Responses** - `200` — Updated - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — Org not found **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/orgs/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/orgs/{id} {#delete-api-v1-orgs-id} **Archive an org (owner)** Soft-delete. Resources stay attached but become inaccessible (the org disappears from `GET /orgs`). Personal orgs cannot be archived — returns `PERSONAL_ORG_PROTECTED`. *Auth: Authenticated (Bearer)* *Operation ID: `archiveOrg`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Archived - `400` — Personal-org-protected ```json { "ok": false, "error": "PERSONAL_ORG_PROTECTED", "message": "Personal orgs cannot be archived.", "retryable": false } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — Org not found **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/orgs/{id}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/orgs/{id}/audit-log {#get-api-v1-orgs-id-audit-log} **Read the org audit log (developer+)** Cursor-paginated audit entries (created_at DESC). Every state-changing org/membership/invite operation appends an entry with actor + target + JSON metadata. Use `before=` for the next page. *Auth: Authenticated (Bearer)* *Operation ID: `getOrgAuditLog`* **Path parameters** - `id` (string) *(required)* **Query parameters** - `limit` (integer) - `before` (string) — Cursor — pass the `next_cursor` from a prior response. **Responses** - `200` — Audit entries - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs/{id}/audit-log" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/orgs/{id}/invites {#get-api-v1-orgs-id-invites} **List org invites (admin+)** Pending + resolved invites for the org. Token is omitted — the inviter only sees the token in the create response or via the email. *Auth: Authenticated (Bearer)* *Operation ID: `listOrgInvites`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Invite list - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs/{id}/invites" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### POST /api/v1/orgs/{id}/invites {#post-api-v1-orgs-id-invites} **Invite a member by email (admin+, role ladder)** Sends an invite email with a magic link. The recipient signs in via the link and is auto-joined with the assigned role. Re-inviting the same email refreshes the existing pending invite (no duplicate row). Inviting an existing member is a no-op (`already_member: true`). Owner role cannot be invited. *Auth: Authenticated (Bearer)* *Operation ID: `createInvite`* **Path parameters** - `id` (string) *(required)* **Request body** (`application/json`) *(required)* - `email` (string) *(required)* — example: `"alice@example.com"` - `role` (string) *(required)* - `message` (string) — Optional personal note shown in the invite email. **Responses** - `200` — Invite refreshed (existing pending row found) or already-member (no-op) - `201` — Invite created (new row) - `400` — Invalid input ```json { "ok": false, "error": "INVALID_INPUT", "message": "Details", "retryable": false, "request_id": "req_..." } ``` - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/orgs/{id}/invites" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/orgs/{id}/invites/{inviteId} {#delete-api-v1-orgs-id-invites-inviteid} **Revoke a pending invite (admin+)** Marks the invite revoked. The audit log records the actor. Cross-org probe protection: passing an invite from a different org returns 404 (no information leak). *Auth: Authenticated (Bearer)* *Operation ID: `revokeInvite`* **Path parameters** - `id` (string) *(required)* - `inviteId` (string) *(required)* **Responses** - `200` — Revoked - `400` — Invite already resolved (accepted/expired/revoked) - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — Invite not found in this org **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/orgs/{id}/invites/{inviteId}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### GET /api/v1/orgs/{id}/members {#get-api-v1-orgs-id-members} **List org members (analyst+)** Returns every member with their role, joined_at, last_seen_at, and per-member spending caps. Decorated with emails from the users table. *Auth: Authenticated (Bearer)* *Operation ID: `listOrgMembers`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Member list - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` **Example** ```bash curl -sS -X GET "$USDR_BASE_URL/api/v1/orgs/{id}/members" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PATCH /api/v1/orgs/{id}/members/{userId} {#patch-api-v1-orgs-id-members-userid} **Change a member's role (admin+, role ladder)** Caller cannot assign a role higher than their own (`ROLE_LADDER_VIOLATION`). Demoting the last Owner is rejected (`LAST_OWNER_PROTECTED`) — promote another member first. *Auth: Authenticated (Bearer)* *Operation ID: `changeMemberRole`* **Path parameters** - `id` (string) *(required)* - `userId` (string) *(required)* **Request body** (`application/json`) *(required)* - `role` (string) *(required)* **Responses** - `200` — Role updated - `400` — Last-Owner protected or invalid role - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — User is not a member of this org **Example** ```bash curl -sS -X PATCH "$USDR_BASE_URL/api/v1/orgs/{id}/members/{userId}" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### DELETE /api/v1/orgs/{id}/members/{userId} {#delete-api-v1-orgs-id-members-userid} **Remove a member or self-leave** Admin+ can remove any member. Any member can self-leave (`userId === caller_userId`). Removing the last Owner is rejected. *Auth: Authenticated (Bearer)* *Operation ID: `removeMember`* **Path parameters** - `id` (string) *(required)* - `userId` (string) *(required)* **Responses** - `200` — Removed - `400` — Last-Owner protected - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — User is not a member of this org **Example** ```bash curl -sS -X DELETE "$USDR_BASE_URL/api/v1/orgs/{id}/members/{userId}" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ### PUT /api/v1/orgs/{id}/members/{userId}/spending-limits {#put-api-v1-orgs-id-members-userid-spending-limits} **Set per-member spending caps (admin+)** Sets daily/monthly/per-tx/approval-threshold caps for a specific member. Pass `null` to clear a cap. Caps stack with org-level and key-level caps; the effective cap is the most restrictive of all three layers. *Auth: Authenticated (Bearer)* *Operation ID: `setMemberSpendingLimits`* **Path parameters** - `id` (string) *(required)* - `userId` (string) *(required)* **Request body** (`application/json`) *(required)* - `daily_spend_limit_usd` (string) — example: `"5000.00"` - `monthly_spend_limit_usd` (string) — example: `"100000.00"` - `per_tx_limit_usd` (string) — example: `"10000.00"` - `approval_threshold_usd` (string) — example: `"1000.00"` **Responses** - `200` — Updated - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Caller's role in the active org is too low for this action. Distinct from `INSUFFICIENT_SCOPE` — scopes gate the API key, roles gate the human. Both must pass. Includes `required_action`, `required_role`, and `current_role` to make UI hints possible. ```json { "ok": false, "error": "INSUFFICIENT_ROLE", "message": "Action 'members.invite' requires role 'admin' or higher", "retryable": false, "suggested_action": "Ask an Owner or Admin in this org to grant you a higher role.", "required_action": "members.invite", "required_role": "admin", "current_role": "developer", "request_id": "req_..." } ``` - `404` — User is not a member of this org **Example** ```bash curl -sS -X PUT "$USDR_BASE_URL/api/v1/orgs/{id}/members/{userId}/spending-limits" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ``` ### POST /api/v1/orgs/{id}/switch {#post-api-v1-orgs-id-switch} **Switch the session's active org** Updates `sessions.active_org_id` for cookie callers. For API-key callers this is a recency touch + acknowledgement only — keys are pinned to their creation org. Caller must be a member of the target org. *Auth: Authenticated (Bearer)* *Operation ID: `switchOrg`* **Path parameters** - `id` (string) *(required)* **Responses** - `200` — Switched - `401` — Missing or invalid API key / session ```json { "ok": false, "error": "UNAUTHORIZED", "retryable": false, "suggested_action": "Provide a valid Bearer API key or session cookie.", "request_id": "req_..." } ``` - `403` — Not a member of the target org **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/api/v1/orgs/{id}/switch" \ -H "Authorization: Bearer $USDR_API_KEY" ``` ## Agent Integrations {#tag-agent-integrations} ### POST /mcp {#post-mcp} **Streamable-HTTP Model Context Protocol entry point** JSON-RPC 2.0 endpoint speaking the Model Context Protocol (MCP) Streamable HTTP transport. Each request is stateless: a fresh MCP server is built per call. Two ways to authenticate: 1. **Bearer API key** (`Authorization: Bearer usdr_…`) — for self-issued tokens from the dashboard. Maps to the legacy scope hierarchy (read_only / payments / user / full). 2. **OAuth 2.1 JWT** (`Authorization: Bearer eyJ…`) — used by Claude.ai custom apps and other spec-compliant MCP hosts. Issued by `/oauth/token`. Tokens carry `mcp.read` and/or `mcp.write` scopes which are enforced per-tool. When called without an `Authorization` header, the response is `401` with `WWW-Authenticate: Bearer realm="mcp", resource_metadata="…", authorization_uri="…"` so MCP clients can auto-discover the OAuth flow. Send a standard MCP `tools/list` first to enumerate the curated tool surface, then `tools/call` to invoke them. Each tool advertises its required scope via the `_meta['mcp/required_scope']` field. The shape of request/response bodies is defined by the MCP spec — see [docs.html](/api/docs). *Auth: Authenticated (Bearer)* *Operation ID: `mcpEndpoint`* **Request body** (`application/json`) *(required)* - `jsonrpc` (string) — example: `"2.0"` - `id` (any) - `method` (string) — example: `"tools/list"` - `params` (object) **Responses** - `200` — MCP JSON-RPC response (single JSON object or text/event-stream). - `401` — Missing or invalid Bearer credentials. The `WWW-Authenticate` header lists OAuth discovery URLs so MCP clients can auto-start the OAuth dance. - `403` — JWT lacks the required scope for the requested tool (e.g. `mcp.write` for a mutating tool). ```json { "ok": false, "error": "INSUFFICIENT_SCOPE", "message": "Tool 'create_invoice' requires the 'mcp.write' OAuth scope.", "retryable": false, "suggested_action": "Re-authorize the app and grant write access.", "request_id": "req_..." } ``` - `405` — GET/DELETE not supported in stateless mode **Example** ```bash curl -sS -X POST "$USDR_BASE_URL/mcp" \ -H "Authorization: Bearer $USDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ /* see request body above */ }' ```