bitroadbitroad
API reference

REST API

Public REST surface, authentication, idempotency, error envelope.

Agent developers

The OpenAPI 3.1 spec is generated from the live API surface and served at GET /api/v1/openapi.json — the authoritative shape reference.

Bitroad's REST surface is a services marketplace with escrow. An agent searches /service-listings, buys a fixed-price service in one call or requests a quote, Bitroad holds the payment, the seller delivers, and the payout releases on acknowledgement. The product catalog for physical goods (/products, /purchase-intents, /orders, /returns) runs on the same rails and is documented after the services routes.

Conventions

  • Versioned under /api/v1/.
  • Money values are stringified integer minor units (bigint) of the resource's currency field (gbp → pence, usd/eur → cents). Products, purchase intents, orders, service quotes, and disputes all expose currency; the *_pence field names denote minor units regardless of currency.
  • All POST/PATCH/DELETE require Idempotency-Key. Replays return the original response. Hash mismatch returns 409 idempotency_conflict.
  • Auth: bearer br_pk_* (platform), bearer br_ik_* (instance), or authjs.session-token cookie (humans).
  • Errors share one envelope:
    {
      "error": {
        "type": "policy_denied",
        "code": "per_tx_cap_exceeded",
        "message": "...",
        "details": { ... },
        "request_id": "..."
      }
    }
    
    Types: unauthenticated, forbidden, policy_denied, confirmation_required, validation, idempotency_conflict, not_found, conflict, payment_failed, payment_action_required, rate_limited, server_error.

Services marketplace

The primary flow. Search listings, buy a fixed-price service in one call or request a quote for bespoke work, and Bitroad holds the charge in escrow until the buyer acknowledges the deliverable. Every route below returns 404 while SERVICES_ENABLED is off (it is on in production).

Discovery (any commerce actor):

  • GET /api/v1/service-listings — active-listing search (q, category, pricing_mode, cursor pagination). ?mine=true returns the calling seller's own listings (any status).
  • GET /api/v1/service-listings/{id}.

Buyer:

  • POST /api/v1/service-listings/{id}/purchase — fixed-price one-call purchase; creates the quote thread directly in accepted and charges immediately.
  • POST /api/v1/service-quotes — request a quote against a quote-mode listing; scope validated against the listing's scope schema.
  • GET /api/v1/service-quotes — list mine (filter status, service_listing_id).
  • GET /api/v1/service-quotes/{id} — detail incl. deliverables (party-scoped; secret payloads decrypt buyer-side only).
  • POST /api/v1/service-quotes/{id}/accept — accept + charge. Cap kickbacks return 409 confirmation_required with a token; re-accept with acknowledged_confirmation: true + confirmation_token.
  • POST /api/v1/service-quotes/{id}/reject — buyer rejects a quote; also how the principal denies a pending_principal_confirmation thread.
  • POST /api/v1/service-quotes/{id}/withdraw — before quoted only.
  • POST /api/v1/service-quotes/{id}/acknowledge-delivery — releases the held payout immediately.

Seller (delegation actions in parentheses):

  • POST /api/v1/service-listings — create (list_create).
  • POST /api/v1/service-listings/{id} — update incl. status flips (list_update).
  • GET /api/v1/sellers/me/service-quotes — incoming threads; ?status=requested is the open-requests poll.
  • POST /api/v1/service-quotes/{id}/quote — submit price + ETA + terms (respond_quote).
  • POST /api/v1/service-quotes/{id}/start — paid → in_progress (respond_quote).
  • POST /api/v1/service-quotes/{id}/deliver — JSON body; kind of url/text/secret inline, file via base64 file_b64 + file_name + file_mime_type (≤ 25 MB, lands on R2) (respond_quote).

Admin (is_admin principal session):

  • POST /api/v1/admin/service-quotes/{id}/cancel — reason required; refunds in full when the thread was charged.
  • POST /api/v1/admin/service-quotes/{id}/force-release — delivered threads only.

Identity

  • POST /api/v1/principals/me/revoke
  • POST /api/v1/platforms (kill-switch is admin-only: POST /api/v1/admin/platforms/{id}/kill)
  • POST /api/v1/instances, GET /api/v1/instances, GET/DELETE /api/v1/instances/{id}, POST /api/v1/instances/{id}/keys/rotate
  • POST /api/v1/delegations, POST /api/v1/delegations/{id}/revoke

Payment envelopes (feature-flagged)

Hidden when AGENT_ENVELOPES_ENABLED is unset OR the calling principal does not have envelopes_enabled = true. Envelope routes return 404 in that case (dark-ship semantics).

  • POST /api/v1/envelopes — create. Triggers a Stripe SetupIntent; response carries client_secret when 3DS is required.
  • GET /api/v1/envelopes — list (principal-scoped).
  • GET /api/v1/envelopes/{id}
  • POST /api/v1/envelopes/{id}/confirm-setup — flip to active after 3DS completes (UI uses this; the Stripe webhook setup_intent.succeeded auto-promotes too).
  • POST /api/v1/envelopes/{id}/topup — fresh SetupIntent for an additional cap. Idempotent.
  • POST /api/v1/envelopes/{id}/revoke — terminal; auto-denies any pending-confirmation intents bound to this envelope.
  • GET /api/v1/envelopes/{id}/drawdowns — append-only journal.

Confirmation-required workflow

When a purchase exceeds an envelope, hits a per-tx / per-day cap, or fails a scope rule, confirmPurchaseIntent returns 409 confirmation_required with a confirmation_token. The intent sits in pending_principal_confirmation for 24 h. The principal clears it via:

  • POST /api/v1/purchase-intents/{id}/confirm — pass acknowledged_confirmation: true and the token.
  • POST /api/v1/purchase-intents/{id}/deny — terminal denial, releases stock.

Notifications fire on kickback per the principal's preferences (in-app + email for humans; signed webhook for non-interactive principals).

Webhook signing-secret rotation

  • POST /api/v1/agent-webhooks/{id}/rotate-secret — issues a new signing secret. Old secret moves to signing_secret_previous with a 7-day grace window so receivers can update verifiers without downtime. The dispatcher signs new deliveries with the current secret only; receivers hold the raw secrets and can verify against either one during the window.

Signing secrets are encrypted at rest

Webhook signing secrets are HMAC keys, so they must be recoverable in raw form to sign — this is reversible encryption, not one-way hashing. The webhook_endpoints.signing_secret and signing_secret_previous columns hold AES-256-GCM ciphertext (packages/core/src/server/crypto/secret-box.ts, encoded iv:tag:ciphertext base64, keyed by INTEGRATIONS_TOKEN_KEY — the same key that guards Shopify access tokens). The raw secret is minted once at registration/rotation and returned to the caller in that one response; it is never stored or returned in plaintext again. The dispatcher decrypts on send before computing the HMAC.

Reads are strict (decryptSecret) since 2026-07-07 — the dual-read rollout fallback is gone. Legacy plaintext rows were encrypted by the one-off pnpm --filter @bitroad/web secrets:backfill (idempotent; also sweeps per-shop Shopify secrets). A value that fails to decrypt now fails the send loudly instead of signing with garbage — re-run the backfill after restoring any pre-2026-07-03 database backup. Per-shop Shopify webhook secrets (seller_integrations.webhook_secret) use the same scheme; the env-based carrier and Stripe webhook secrets are unchanged (see carriers.md).

Addresses, payment methods, agent webhooks

  • POST/GET /api/v1/addresses, DELETE /api/v1/addresses/{id} — country accepts GB (default) or US. US addresses require a two-letter region (state) and a ZIP-shaped postcode (12345/12345-6789); GB addresses require a UK-shaped postcode. Outputs include region (null for GB).
  • POST/GET /api/v1/payment-methods, DELETE /api/v1/payment-methods/{id}
  • POST/GET /api/v1/agent-webhooks (instance-only), DELETE /api/v1/agent-webhooks/{id}

Catalog (products)

  • GET /api/v1/products — search. Filter set:
    • q, category (slug), category_subtree=true, seller_id, certifications, restricted (allow / exclude_age / exclude_all), min_seller_tier (basic / verified / premium), improves_on_statutory, spec (containment), spec_min, spec_max, page_size, cursor.
  • GET /api/v1/products/{id} — full structured detail (specs, return_policy, seller + trust signals).
  • POST /api/v1/products — listing-side create (idempotency-key required). The programmatic seller surface is under /api/v1/seller/....
  • GET /api/v1/categories[?slug=...] — full tree or subtree. Each leaf carries its JSON Schema, filterable, rangeable.
  • GET /api/v1/categories/{slug} — one node + spec schema.
  • GET /api/v1/sellers/{id} — public profile + trust signals (stale: true when computed_at > 36 h old).

Filters out flagged + out-of-stock products. Paginated by opaque cursor (monotonic id). See catalog.md for spec / taxonomy details.

Purchase intents

  • POST /api/v1/purchase-intents — create. Reserves stock, snapshots price + VAT + shipping, evaluates delegation policy. Shipping is topped up, not flat: same-seller, same-address purchases made while earlier orders are unshipped get combined postage (see payments.md). Body: { product_id, quantity, address_id?, payment_method_id? }.
  • GET /api/v1/purchase-intents/{id} — read.
  • POST /api/v1/purchase-intents/{id}/confirm — re-validate within 1p, charge via Stripe Connect destination charges, create order. Body: { acknowledged_confirmation, confirmation_token? }.
  • POST /api/v1/purchase-intents/{id}/cancel — release reservation.

Orders

  • GET /api/v1/orders?status=&page_size=&cursor=
  • GET /api/v1/orders/{id} — full detail incl. items + shipment.

Returns

  • POST /api/v1/orders/{id}/returns — initiate. Body: { reason: 'cooling_off_statutory'|'cooling_off_extended'|'defective', description? }. Auto-approves when eligible and starts the seller-funded label deadline (2 business days from approval — the seller uploads a prepaid label via POST /api/v1/seller/returns/{id}/label, or the buyer is auto-refunded), sends notifications. Idempotent on order_id (returns existing return if any).
  • GET /api/v1/returns?order_id=&status=&page_size=&cursor=
  • GET /api/v1/returns/{id}
  • GET /api/v1/returns/{id}/label — short-lived URL.

Sellers

  • POST /api/v1/sellers — register. Accepts country (any ISO 3166-1 alpha-2, GB default; Stripe validates Connect support at account creation). Any country other than GB/US is a non-UK deemed-supplier seller that may only sell to UK buyers by dispatching from UK stock. Mints a Stripe Connect Express account in that country and sets the seller's default settlement currency (US → usd; GB and every other country → gbp; switchable pre-listing to gbp/usd/eur from store settings); flips charges-enabled on account.updated webhook. Also flips the principal's seller_api_enabled flag to true.
  • GET /api/v1/sellers/me — output includes country + currency.
  • POST /api/v1/sellers/me/onboarding-link — create Stripe Account Link.

Seller programmatic surface

Mounted under /api/v1/seller/.... Auth: any commerce actor whose delegation evaluates the relevant seller_action for the bound seller. principal_self who owns the seller is unconditionally authorised. Platform actors are refused.

  • GET /api/v1/seller/me — bound seller record.
  • GET /api/v1/seller/onboarding-status — read-only Stripe Connect status; agents poll until stripe_charges_enabled = true.
  • GET /api/v1/seller/listings, POST /api/v1/seller/listings — list + create (mirrors /products POST but gated by the seller evaluator).
  • GET /api/v1/seller/listings/{id} — fetch listing.
  • POST /api/v1/seller/listings/{id} — update listing (price, description, specs, return policy, etc.).
  • POST /api/v1/seller/listings/{id}/archive — soft-delete; refused while pending intents reference the product.
  • POST /api/v1/seller/listings/{id}/stock — absolute (stock) or relative (delta) update. Resulting count must be ≥ stock_reserved.
  • GET /api/v1/seller/orders — list (filter: status, cursor).
  • GET /api/v1/seller/orders/{id} — single order detail with items
    • shipment.
  • POST /api/v1/seller/orders/{id}/ship — mark shipped. Idempotent on (order_id, tracking_number).
  • POST /api/v1/seller/orders/{id}/tracking — attach / replace tracking on an already-shipped or paid-awaiting-fulfilment order.
  • POST /api/v1/seller/orders/{id}/deliver — mark delivered (empty body; order id is path-bound).
  • POST /api/v1/seller/orders/{id}/cancel — seller-initiated cancel-and-refund (seller.cancel_order); reason required (idempotency-key required). Allowed only from paid_awaiting_fulfillment — full Stripe refund with proportional application-fee reversal, restores stock, marks the order cancelled, notifies the buyer. Once shipped, the buyer-side returns/disputes flow is the right rail.
  • GET /api/v1/seller/returns, GET /api/v1/seller/returns/{id}.
  • POST /api/v1/seller/returns/{id}/label — multipart upload of the seller-funded prepaid return label (PDF/PNG/JPEG, 10 MB cap), due within 2 business days of approval.
  • POST /api/v1/seller/returns/{id}/accept — approved → received; short-circuits the carrier-webhook wait.
  • POST /api/v1/seller/returns/{id}/reject — inspection_notes required; refuses if the return has already refunded.
  • GET /api/v1/seller/reviews.
  • POST /api/v1/seller/reviews/{id}/respond — one-shot seller response.

Buyer-side review submission is mounted under /orders/{id}/review:

  • POST /api/v1/orders/{id}/review — buyer submits a verified-purchase review. Refused unless the order is delivered and not already reviewed.

The error envelope includes these seller-surface codes: seller_action_not_allowed, seller_not_owned_by_delegation, product_denied, product_not_allowed, seller_action_requires_confirmation.

Chat

  • POST /api/v1/chat — orchestrator turn.
    • Server-paid: omit X-Anthropic-Api-Key.
    • Caller-paid: X-Anthropic-Api-Key: sk-ant-.... The header is redacted at the framework boundary, never logged, never persisted.

Inbound webhooks

  • POST /api/v1/webhooks/stripe — signature-verified, idempotent on event.id.
  • POST /api/v1/webhooks/carriers/{carrier} — env-gated: returns 404 by default unless CARRIER_WEBHOOKS_ENABLED=1 and a per-carrier shared secret is set. Events are HMAC-verified via the x-carrier-signature: t=<unix>,v1=<hex> header (401 invalid_signature on failure) and deduped on the carrier's own event_id (replays return 200 { received, replay: true }). Outbound delivery triggers markOrderDelivered; return-leg delivery (matched on (return_id, tracking_number)) triggers markReturnReceivedAndRefund.

OpenAPI

GET /api/v1/openapi.json — generated 3.1 spec. A smoke test asserts the spec is valid 3.1 and includes a few representative core paths (/products, /purchase-intents, /orders/{id}/returns, /chat), not exhaustive coverage of every route.

Rate limits

Scope Default capacity Refill/sec
instance.writes 120 burst 1
instance.reads 240 burst 4
platform.writes 1200 burst 10
platform.reads 2400 burst 40
principal.writes 60 burst 0.5
principal.reads 120 burst 2
principal.chat 60 burst 0.5
ip.public_reads 120 burst 2

ip.public_reads keys per client IP and guards unauthenticated public endpoints; the read buckets back GET routes for their respective actor kinds.

429 includes Retry-After and a body with retry_after_seconds.

Idempotency

  • Header Idempotency-Key required on every write.
  • Scope: (key, route, actor_principal_id).
  • TTL: 24 h.
  • Replays return the original (status, body).
  • Hash mismatch → 409 idempotency_conflict.
  • Stripe-side idempotency derived from our key: bitroad:<route>:<key>.

Helper-defined routes (definePost) get this lifecycle automatically. The hand-rolled write routes below can't use definePost (path params, non-200 success codes, admin/ownership gates, one-time-secret bodies) so they run the same lifecycle inline via withIdempotentReplay — and now perform full stored-body replay, not just header enforcement:

Route Payload hashed Replay note
POST /api/v1/platforms body Replays the first 201 body, so the one-time api_key is returned verbatim on retry — the platform key is minted exactly once.
POST /api/v1/admin/platforms/{id}/kill body + path id Reusing a key for a different platform → 409.
POST /api/v1/delegations/{id}/revoke path id Retry with the same key returns the original 200 instead of already_revoked. Reusing a key for a different delegation → 409.
POST /api/v1/principals/me/revoke {} Self-revoke destroys the acting credential, so a live retry 401s at auth before reaching the replay; the stored record only shields concurrent in-flight retries that authenticated before the revoke committed.

Key-only relaxation (multipart uploads)

The multipart upload routes — POST /api/v1/uploads/product-image, POST /api/v1/uploads/seller-logo, and POST /api/v1/seller/returns/{id}/label — send multipart bodies, so there is no canonical JSON to hash. Their Idempotency-Key is accepted, not required, and replay is KEY-ONLY, keyed on (key, route, actor_principal_id) alone: a second call with the same key replays the first stored payload regardless of the new file bytes, and there is no 409 conflict outcome. Without a key each call uploads independently.