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
currencyfield (gbp→ pence,usd/eur→ cents). Products, purchase intents, orders, service quotes, and disputes all exposecurrency; the*_pencefield names denote minor units regardless of currency. - All POST/PATCH/DELETE require
Idempotency-Key. Replays return the original response. Hash mismatch returns 409idempotency_conflict. - Auth: bearer
br_pk_*(platform), bearerbr_ik_*(instance), orauthjs.session-tokencookie (humans). - Errors share one envelope:
Types:{ "error": { "type": "policy_denied", "code": "per_tx_cap_exceeded", "message": "...", "details": { ... }, "request_id": "..." } }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=truereturns 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 inacceptedand charges immediately.POST /api/v1/service-quotes— request a quote against a quote-mode listing;scopevalidated against the listing's scope schema.GET /api/v1/service-quotes— list mine (filterstatus,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 409confirmation_requiredwith a token; re-accept withacknowledged_confirmation: true+confirmation_token.POST /api/v1/service-quotes/{id}/reject— buyer rejects a quote; also how the principal denies apending_principal_confirmationthread.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=requestedis 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;kindofurl/text/secretinline,filevia base64file_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—reasonrequired; refunds in full when the thread was charged.POST /api/v1/admin/service-quotes/{id}/force-release—deliveredthreads only.
Identity
POST /api/v1/principals/me/revokePOST /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/rotatePOST /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 carriesclient_secretwhen 3DS is required.GET /api/v1/envelopes— list (principal-scoped).GET /api/v1/envelopes/{id}POST /api/v1/envelopes/{id}/confirm-setup— flip toactiveafter 3DS completes (UI uses this; the Stripe webhooksetup_intent.succeededauto-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— passacknowledged_confirmation: trueand 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 tosigning_secret_previouswith 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}—countryacceptsGB(default) orUS. US addresses require a two-letterregion(state) and a ZIP-shapedpostcode(12345/12345-6789); GB addresses require a UK-shaped postcode. Outputs includeregion(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: truewhencomputed_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 viaPOST /api/v1/seller/returns/{id}/label, or the buyer is auto-refunded), sends notifications. Idempotent onorder_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. Acceptscountry(any ISO 3166-1 alpha-2,GBdefault; 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 settlementcurrency(US → usd; GB and every other country → gbp; switchable pre-listing to gbp/usd/eur from store settings); flips charges-enabled onaccount.updatedwebhook. Also flips the principal'sseller_api_enabledflag to true.GET /api/v1/sellers/me— output includescountry+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 untilstripe_charges_enabled = true.GET /api/v1/seller/listings,POST /api/v1/seller/listings— list + create (mirrors/productsPOST 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);reasonrequired (idempotency-key required). Allowed only frompaid_awaiting_fulfillment— full Stripe refund with proportional application-fee reversal, restores stock, marks the ordercancelled, 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_notesrequired; 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 isdeliveredand 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.
- Server-paid: omit
Inbound webhooks
POST /api/v1/webhooks/stripe— signature-verified, idempotent onevent.id.POST /api/v1/webhooks/carriers/{carrier}— env-gated: returns 404 by default unlessCARRIER_WEBHOOKS_ENABLED=1and a per-carrier shared secret is set. Events are HMAC-verified via thex-carrier-signature: t=<unix>,v1=<hex>header (401invalid_signatureon failure) and deduped on the carrier's ownevent_id(replays return 200{ received, replay: true }). Outbound delivery triggersmarkOrderDelivered; return-leg delivery (matched on(return_id, tracking_number)) triggersmarkReturnReceivedAndRefund.
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-Keyrequired 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.