Base URLs:
- Production:
https://api.sellable.shop - Local development:
http://localhost:8000
Platform revamp note: this reference documents the original agent/console surfaces in depth. The newer platform surfaces are documented where they live: canonical commerce + protocols in
docs/AGENT_INTEGRATION.md, connectors/carriers/providers indocs/CONNECTORS.md, and the endpoint table inREADME.md#api-reference. Every route below is additionally served under/v1/*identically (prefix-rewriting middleware, §42) — e.g./v1/commerce/search— and every response carries anX-Trace-Idheader.
SELLABLE keeps two authentication surfaces separate (WORKFLOW.md §55):
- Agent-facing endpoints (
/agent/*,/orders/*) require an agent API key (X-Agent-Keyheader) or an HMAC-SHA256 signed request withAuthorization: Bearer <key>,X-Agent-Id,X-Timestamp,X-Nonce, andX-Signatureheaders. The signature bindstimestamp.nonce.agent_id.method.path.query.body-sha256. Nonces are claimed persistently, so replays fail across restarts and replicas. - Mutating agent routes require signed requests outside dev/test:
/agent/orders.create,/agent/consents.request,/agent/buyer/run,/orders/{id}/payment,/orders/{id}/payment/retryaccept a staticX-Agent-Keyonly whenSELLABLE_ENVIRONMENTisdevelopmentortest. In production they require the HMAC-signed-request path. Quote/catalog reads stay available with either method. - Console/merchant endpoints require a merchant session
(
get_merchant_session): a Supabase access token (Authorization: Bearer) in production, or the demoX-Agent-Keyin local demo mode. Policy updates (PUT /console/policy) and refunds additionally require theownerrole. - Trace correlation: every agent route accepts an optional
X-Trace-Idheader (^trc_[0-9a-f]{32}$, 422 on malformed values; overrides any bodytrace_id). Quote → order → consent → payment issued with one header share a single replayable trace.
Public discovery endpoints (/.well-known/agents.json, /llms.txt,
/catalog.ai.json, /health) require no authentication.
Returns service health status. The console branches on status only
(apps/merchant-console/components/stat-strip.tsx:59).
Development (SELLABLE_ENVIRONMENT in development/test —
services/commerce/sellable/main.py:301-308):
{
"status": "ok",
"environment": "development",
"database": "connected",
"razorpay_configured": false,
"cors_origins": ["https://sellable.shop", "http://localhost:3000"]
}Production (default when SELLABLE_ENVIRONMENT is unset —
services/commerce/sellable/main.py:407-419): minimal liveness shape only.
{
"status": "ok",
"database": "connected"
}environment, razorpay_configured, and cors_origins are intentionally
omitted in production. A reachable backend reporting "status": "ok" is
live; only network errors / 5xx mean offline.
Create a policy-evaluated candidate cart from a buyer request.
Headers: X-Agent-Key: sellable_demo_key_001
Request:
{
"message": "I need coffee for my desk",
"intent": {
"buyer_agent_id": "buyer_001",
"budget_ceiling_paise": 200000,
"allowed_categories": ["accessories", "gifting", "snacks"],
"purpose": "Buy coffee",
"expires_at": "2026-08-28T15:00:00Z"
},
"requested_sku": null,
"quantity": 1,
"buyer_offer_paise": null,
"request_upsell": true
}Response (SellerDecision):
{
"trace_id": "trc_abc123",
"action": "QUOTE_READY",
"response_message": "Here is a policy-valid candidate cart.",
"cart": {
"mandate_id": "...",
"intent_ref": "...",
"items": [{"sku": "COFFEE-BEANS-01", "quantity": 1, "unit_price_paise": 194800, "offered_price_paise": 194800}],
"subtotal_paise": 194800,
"discount_paise": 0,
"total_paise": 194800,
"negotiation_round": 0
},
"policy_decision": {
"verdict": "ALLOW",
"reason_code": null,
"reasoning_summary": "Cart is within budget and all policy rules passed."
},
"selected_product": {"sku": "COFFEE-BEANS-01", "title": "...", "price_paise": 194800},
"upsell_product": null,
"tool_calls": ["catalog.search", "quotes.create", "policy.evaluate"]
}Actions: QUOTE_READY, COUNTERED, NEEDS_HUMAN_APPROVAL, DENIED, NO_MATCH
Machine-readable merchant manifest for AI buyer discovery.
Response:
{
"name": "Sellable Demo Store",
"merchant_id": "mrc_demo_store",
"protocol_version": "0.1",
"capabilities": ["catalog.search", "catalog.get", "quote.create", "quote.negotiate", "consent.request", "orders.create", "orders.status"],
"discovery": {"catalog": "/catalog.ai.json", "instructions": "/llms.txt"},
"transaction_endpoints": {
"catalog_search": "/agent/catalog.search",
"catalog_get": "/agent/catalog.get",
"quote_create": "/agent/quotes.create",
"quote_negotiate": "/agent/quotes.negotiate",
"payment": "/orders/{order_id}/payment"
},
"payment": {"provider": "razorpay", "mode": "test", "settlement_authority": "signed_webhook"}
}Plain-text instructions for AI buyers. Returns text/plain.
Machine-readable catalog with SKUs, prices, floors, and stock.
Headers: X-Agent-Key: sellable_demo_key_001
Request:
{"query": "coffee", "categories": []}Response: list[Product]
Headers: X-Agent-Key: sellable_demo_key_001
Request:
{"sku": "COFFEE-BEANS-01"}Response: Product
Headers: X-Agent-Key: sellable_demo_key_001
Request: Same as /agent/seller/respond
Response: SellerDecision
Headers: X-Agent-Key: sellable_demo_key_001
Request: Same as /agent/seller/respond (include buyer_offer_paise for negotiation)
Response: SellerDecision
Issue transaction-bound, single-use consent for an order.
Request:
{"order_id": "ord_..."}Response:
{
"consent_id": "con_...",
"order_id": "ord_...",
"amount_paise": 69900,
"payee_id": "mrc_demo_store",
"single_use": true,
"status": "ISSUED"
}Consent is refused while an order is held for human approval
(409 Order requires merchant approval before consent can be issued).
Consent vocabulary: the Consent record enum is
ISSUED/USED/EXPIRED/REVOKED (contracts.py:46-50), but
order/transaction summaries surface USED as CONSUMED
(main.py:858-873). Clients must accept both tokens as spent
(app/dashboard/activity/page.tsx:130-131).
Create an authoritative order (idempotent). A duplicate call with the same
idempotency_key returns the original order with "replayed": true.
Request:
{
"intent": { "buyer_agent_id": "buyer_001", "budget_ceiling_paise": 600000, "allowed_categories": ["accessories", "gifting", "snacks"], "purpose": "Buy coffee", "expires_at": "2026-08-28T15:00:00Z" },
"message": "I need coffee for my desk",
"idempotency_key": "idem_...",
"request_upsell": true
}Response:
{
"order_id": "ord_...",
"trace_id": "trc_...",
"status": "AWAITING_CONSENT",
"amount_paise": 84900,
"quote_id": "cart_...",
"idempotency_key": "idem_...",
"requires_approval": false
}Request: {"order_id": "ord_..."}
Response: {"order_id", "status", "amount_paise", "payment_id", "trace_id"} —
payment_id is resolved from the settlement ledger (null until captured).
Issue a real provider refund for a paid order. Calls the Razorpay refund
API (test mode), persists the provider refund id, and settles the order.
Requires a merchant session with the owner role (despite the /agent/
prefix, buyer agents cannot self-refund).
Request: {"order_id": "ord_...", "reason": "merchant_initiated", "amount_paise": null, "idempotency_key": null}
(omit amount_paise for a full refund; omit idempotency_key for a
deterministic per-(order, amount) key — retries never double-refund).
Response: Refund confirmation with refund_id, provider_refund_id,
refund_status (processed), amount_paise, and trace_id. Partial
refunds keep the order PAID; full refunds move it to REFUNDED.
Run the reference buyer agent through a full mission.
Request:
{
"buyer_agent_id": "buyer_ref_001",
"message": "I need a desk setup under 6000 rupees",
"budget_ceiling_paise": 600000,
"allowed_categories": ["accessories", "gifting", "snacks"],
"purpose": "Desk setup",
"request_upsell": true,
"requested_sku": null,
"quantity": 1,
"buyer_offer_paise": null
}(requested_sku/quantity/buyer_offer_paise enable targeted quotes and
first offers; the buyer flow ends at consent — verify settlement afterwards
via order status. The buyer independently enforces mandate expiry, its own
budget ceiling, and catalog grounding before returning READY_FOR_CONSENT.)
Response (BuyerResult):
{
"trace_id": "trc_...",
"action": "READY_FOR_CONSENT",
"buyer_summary": "A catalog-grounded, policy-valid cart is ready for explicit transaction consent.",
"merchant_manifest": {...},
"seller_decision": {...},
"order_id": "ord_...",
"consent_id": "con_...",
"steps": ["DISCOVER", "RESEARCH", "REQUEST_QUOTE", "EVALUATE", "ORDER", "CONSENT"]
}For a READY_FOR_CONSENT result the buyer agent also creates the order and
requests consent (order_id/consent_id are populated). For DENIED,
NO_MATCH, or NEEDS_HUMAN_APPROVAL results the buyer stops after evaluation.
Actions: READY_FOR_CONSENT, NEEDS_HUMAN_APPROVAL, DENIED, NO_MATCH
Resumable, merchant-scoped views of reference-buyer runs against your own
store. The mission row is a pointer, never a second financial state machine —
every read re-derives state from the authoritative order row + ledger
(services/commerce/sellable/contracts.py:491-497,
services/commerce/sellable/buyer_missions.py:19).
| Endpoint | Method | Description |
|---|---|---|
/console/agent/buyer/run |
POST | Start a buyer mission (same body as POST /agent/buyer/run; persists a resumable mission, stamps mission_id on the result — main.py:1815-1853) |
/console/buyer-missions · /buyer-missions |
GET | Recent mission snapshots (limit default 20, clamped to 100 — main.py:1864-1876) |
/console/buyer-missions/{mission_id} |
GET | One mission's authoritative, re-derived state (unknown id → 404 — main.py:1886-1903) |
/console/buyer-missions/{mission_id}/continue |
POST | Resume the SAME order: verify approval, reuse/issue consent, start payment via the existing PaymentService (main.py:1906-1929, buyer_missions.py:361-431) |
Mission states (contracts.py:245-263): NEEDS_HUMAN_APPROVAL,
APPROVED, CONSENT_READY, PAYMENT_PENDING, PAID, VERIFIED,
PAYMENT_FAILED, ABORTED, REFUNDED, DENIED.
DENIEDmissions haveorder_id: nullandorder_status: null— policy refused before any order existed, so there is nothing to continue (contracts.py:503-504,buyer_missions.py:278-300);continueon aDENIEDmission answers 409.continuesemantics: reuses a live consent when valid, issues one when missing/expired, and consumes it through the existing payment service (idempotent per order — repeated calls cannot duplicate a payment). On a single-use race (ConsentValidationErrorbetween check and consume) it re-derives state and retries exactly once with one fresh consent, else returns the truthful derived state (buyer_missions.py:370-428).
Start a Razorpay test-mode payment after consuming consent.
Request:
{
"consent_id": "cns_..."
}Response: PaymentAttempt
Perform one bounded, idempotent retry after a verified payment failure.
Response: PaymentAttempt
Receives Razorpay payment webhook. Verified via X-Razorpay-Signature header
(mandatory, fail-closed; 401 on missing/invalid signature). Handled events:
payment.captured, payment_link.paid, payment.failed,
payment_link.cancelled. Duplicate deliveries are idempotent via persisted
delivery claims; unexpected-but-real transitions answer 409 with a
webhook.unexpected_state ledger row — never a silent 500. Rate-limited
(120/minute) instead of exempt.
Headers: X-Razorpay-Signature: <signature>
Response: PaymentAttempt
Receives Stripe test-mode webhooks through the same orchestrated boundary
as Razorpay (PaymentService.handle_stripe_webhook, stripe-actor ledger
rows): Stripe-Signature HMAC verification (401 on missing/invalid, 503
when unconfigured), tenant-aware order resolution via
metadata.local_order_id, persisted idempotent delivery claims, exact
amount_received matching (webhook.amount_mismatch never settles and
never burns the delivery key), strict state-machine settlement
(webhook.unexpected_state → 409), duplicate-capture flagging. Handled
events: payment_intent.succeeded, payment_intent.payment_failed;
anything else returns {"status": "ignored"}. Unknown orders → 404 (never
a settlement attempt).
Headers: Stripe-Signature: t=<timestamp>,v1=<signature>
Response: {status, order_id} (CAPTURED / FAILED attempt status)
Issue a real provider refund for a PAID (or FULFILLED) order. Requires
a merchant session with the owner role.
Query params: reason (default merchant_initiated, ≤500 chars),
amount_paise (optional; omit for a full refund — partial refunds keep the
order PAID), idempotency_key (optional; deterministic default per
(order, amount)).
Response:
{
"refund_id": "rfnd_...",
"order_id": "...",
"amount_paise": 69900,
"provider_payment_id": "pay_...",
"provider_refund_id": "rfnd_...",
"refund_status": "processed",
"reason": "merchant_initiated",
"trace_id": "trc_..."
}The refund payload carries refund_status only — there is no top-level
status key (services/commerce/sellable/refunds.py:161-172). Same shape
applies to POST /agent/refunds.create.
All console endpoints require a merchant session (Supabase JWT, or the demo
X-Agent-Key in local demo mode).
List endpoints accept limit/offset and clamp to a 500-row window
(main.py:2837-2846 transactions, main.py:3080-3091 approvals,
main.py:3042-3049 events): limit defaults to 500 (events: 200), values
are clamped to max 500 (max(1, min(limit, 500))), offset is floored at 0.
The Orders page shows a truncation notice when the 500-row cap is hit
(app/dashboard/transactions/page.tsx:84-96,281-283).
| Endpoint | Method | Description |
|---|---|---|
/console/transactions · /transactions |
GET | Transaction list |
/console/transactions/{id} · /transactions/{id} |
GET | Transaction detail with ledger events |
/transactions/{id}/events |
GET | Ledger events for a transaction |
/console/events · /activity |
GET | XAI Ledger events (limit clamped to 500) |
/activity/stream |
GET | SSE live ledger stream |
/console/approvals · /approvals |
GET | Orders held for human approval — rows always carry status: "PENDING" (main.py:3122); post-action state is derived from the order (consent/payment), not the list rows |
/console/approvals/{id}/approve · /approvals/{id}/approve |
POST | Pre-validated approve + issue consent (unknown order → 404) → top-level {"status": "approved", ...} (main.py:3131-3183) |
/console/approvals/{id}/reject · /approvals/{id}/reject |
POST | Reject (aborts; cancels a live payment link first when PAYMENT_PENDING) → top-level {"status": "rejected", ...} (main.py:3197-3224) |
/console/orders/{id}/fulfill · /orders/{id}/fulfill |
POST | Mark a paid order FULFILLED |
/console/orders/{id}/simulate-capture · /simulate-failure |
POST | Dev-only verified-webhook simulation (disabled unless dev/test env — 403 in production via main.py:2622-2623 — flagged simulated in the ledger). A 200 does NOT imply settlement: on amount mismatch the attempt stays PENDING and the order stays PAYMENT_PENDING (payments/service.py:402-423); only attempt.status of CAPTURED/FAILED counts |
/console/insights · /growth |
GET | Growth metrics (revenue counts PAID orders) |
/console/policy |
GET/PUT | Read merchant policy / owner-only update (re-validates). GET reports is_default: true when no policy row exists yet (first-run defaults — review and save, not an error) |
/console/onboarding/progress |
GET/PUT | Shared-wizard pointer: {current_step, completed_steps[], data{}}. GET returns the default first-step pointer (never 404); PUT validates step ids (unknown → 409) and merges per-step payloads. Tenant-scoped; demo mode never calls it (localStorage instead) |
/console/onboarding/readiness |
GET | Readiness checks + evidence (provider, payment_credentials_valid, webhook_last_verified_at) + live_blockers (failing required checks gating live transactions). payment_health reflects validated credentials, not mere presence |
/catalog/products |
POST | Add a catalog product. SKU contract: ^[A-Z0-9-]+$, max 64 chars (contracts.py:93); the catalog form validates client-side before POST (app/dashboard/catalog/page.tsx:167-169) |
/agents/status |
GET | Agent + payment-rail health |
/console/store |
GET | The authenticated merchant's own store record |
/console/onboarding |
POST | Create the verified user's own merchant store |
/console/agent/seller/respond |
POST | Conversational checkout via the seller agent |
/console/agent/buyer/run |
POST | Run the reference buyer against your own store |
/console/orders |
POST | Create an order from a chat quote (idempotent) |
/console/orders/{id}/consent |
POST | Issue single-use consent for the order |
/console/orders/{id}/payment |
POST | Start a Razorpay test-mode payment |
/console/orders/{id}/payment/retry |
POST | One bounded retry after a verified failure |
/console/checkout/session |
GET/POST | Restore/persist the durable checkout session |
/console/checkout/sessions |
GET | Lightweight chat-history list |
/console/checkout/session/{id} |
GET/PATCH/DELETE | Open, rename/archive a chat session — PATCH (main.py:4387) partially updates title (blank clears to NULL) and/or archived; DELETE (main.py:4417) soft-archives only (row kept; commerce rows never touched); closing a session sets ABANDONED (main.py:4271) |
/console/catalog · /console/catalog/{sku} |
GET | The merchant's own catalog / one product |
Chat checkout (POST /console/agent/seller/respond +
/console/orders → /consent → /payment): the chat client refreshes the
authoritative order before pay; on a 409 single-use race it re-issues consent
once and retries (app/dashboard/chat/page.tsx:1195-1217). Restored snapshot
quotes are provisional until a fresh seller turn
(app/dashboard/chat/page.tsx:639-647). Chat offers MARK FULFILLED
(PAID → FULFILLED, main.py:3232-3238) alongside refund.
Merchant-issued credentials that let an external AI buyer call the agent gateway. Only the SHA-256 hash is stored; the plaintext is returned exactly once at creation/rotation. Owner role required for mutations.
| Endpoint | Method | Description |
|---|---|---|
/console/agent-keys |
GET | List keys (prefix + metadata, never plaintext) |
/console/agent-keys |
POST | Issue a key → { plaintext, key } (shown once) |
/console/agent-keys/{key_id}/rotate |
POST | Revoke + replace; new plaintext returned once |
/console/agent-keys/{key_id} |
DELETE | Revoke; requests with the key stop authenticating |
Issued keys authenticate X-Agent-Key gateway requests scoped to the issuing
merchant (buyer agent id defaults to the key's buyer_agent_id) and are also
accepted on the HMAC-signed path.
SELLABLE_ACP_ENABLED=1 exposes an ACP-shaped read-only view of canonical
AUTHORIZED checkouts. No conformance claim; no partner placement.
| Endpoint | Method | Description |
|---|---|---|
/acp/status |
GET | Scaffold status: {enabled, version, spec_reference, note} (always visible, unauthenticated discovery) |
/acp/checkout/sessions |
POST | {checkout_id} → ACP session state (settled totals, auth binding, idempotency guidance). Disabled → 501 acp_disabled; unknown checkout → 404; non-AUTHORIZED checkout → 409. Creates nothing, moves no money |
FastAPI errors may return either a plain string or a structured object
(apps/merchant-console/lib/api.ts:693-722 maps both via
ApiError.errorCode):
{
"detail": "Error message"
}{
"detail": {"code": "onboarding_required", "message": "..."}
}Machine-readable codes (detail.code):
onboarding_required(403,services/commerce/sellable/merchant_auth.py:256-262) — authenticated user has no linked merchant; complete merchant onboarding.auth_not_configured(401,services/commerce/sellable/merchant_auth.py:281-321, constantAUTH_NOT_CONFIGURED_CODE) — frontend/backend auth modes disagree (demo console against a production backend, or vice versa). See Authentication above; the console surfaces deployment guidance, not a generic "unauthorized". Also returned when a development backend points at a remote database (demo-key sessions require a local database).acp_disabled(501) — the ACP scaffold is disabled; setSELLABLE_ACP_ENABLED=1to expose it.409 Consent is not available for use(services/commerce/sellable/consent.py:64) — stale or already-consumed single-use consent. Refresh the authoritative order and re-issue consent once, then retry payment (chat checkout does this automatically —app/dashboard/chat/page.tsx:1195-1217).422 X-Trace-Id must match ^trc_[0-9a-f]{32}$(services/commerce/sellable/main.py:310-328) — malformedX-Trace-Idheader or bodytrace_id(contracts.py:225,288). Drop stale persisted ids and let the server mint a freshtrc_…id.
| Status | Meaning |
|---|---|
| 400 | Bad request or invalid state transition |
| 401 | Missing/invalid agent credentials, merchant session, or webhook signature |
| 403 | Valid format but unknown key or unauthorized merchant |
| 404 | Resource not found (e.g., unknown SKU or order) |
| 409 | Idempotency conflict, blocked state transition, or consent reuse |
| 422 | Malformed X-Trace-Id / body trace_id (must match ^trc_[0-9a-f]{32}$) |
| 501 | Disabled optional scaffold (ACP) |
| 502 | Razorpay request failed |
| 503 | Razorpay not configured |
Every response (success and error) carries an X-Trace-Id correlation id —
echoed when the caller supplies a valid one, minted otherwise
(services/commerce/sellable/tracing.py:78-90). The console captures it as
ApiError.traceId and shows it as Reference: trc_… on Settings/Selling
rules errors for support.