Villa Commerce API
Build an alternate shop on Villa Market fulfilment — the same model Ocado uses with Waitrose. You own the customer experience. Villa remains the grocer: catalog identity, branch price, stock, basket, order, payment, and vans.
Public host: https://developers.villamarket.ai
Demo shop and payment links: https://shop.villamarket.ai.
api.villamarket.ai will serve the same API once it moves onto this host's
edge; until then use developers.villamarket.ai in every config.
You never call Villa’s internal website APIs. This host is the only commerce surface partners use.
Two ways in
| You are | You use | You get |
|---|---|---|
| A merchant — a marketplace, shop, app or assistant selling to your own customers | your merchant key (X-Partner-Key) plus each shopper's sign-in token |
a royalty on every paid order your shoppers place (below) |
| An individual — someone shopping for themselves with their own AI | a personal key from https://agent.villamarket.ai as Authorization: Bearer vpk_… on the MCP server |
your own basket and orders on Villa's direct channel; no merchant sees them |
Demos of both: https://shop.villamarket.ai (fictional merchants: a storefront,
a chat shop, shoppable recipes, their earnings) and https://agent.villamarket.ai
(Villa's own agent and the connect guide for your AI).
Surfaces
| Path | Role |
|---|---|
GET /docs |
This guide |
POST /graphql |
GraphQL |
/mcp |
MCP (Streamable HTTP) for agents |
GET /schema.graphql |
Download the SDL |
GET /v1/products… |
Product catalog (REST, cached snapshot) — see below |
GET /pay/<token> |
Payment link: the whole checkout of one order, and where it is paid |
GET /pay/<token>/checkout.json |
The same checkout as JSON (for your app or agent) |
GET /shop, /shop/lumora, /shop/suk, /shop/krua, /shop/earnings |
Fictional merchant demos built only on this API, and their earnings |
POST /agent/chat, GET /agent/status |
Hosted shopping assistant (see below) |
GET /agent, /llms.txt, /.well-known/mcp.json |
Villa's own agent page, and how an AI agent connects |
GET /health |
Liveness |
Auth
Send your key on every GraphQL and MCP call:
X-Partner-Key: <key we issue you>
Today the catalog also answers without a key: products, categories and branches (the anonymous-catalog policy is still open). Anything with a basket, order, payment or earnings needs the key.
The key maps to your partnerId and registered orderSource (stamped on
quotes and orders).
Shopper-scoped operations (basket, quote, order, payment) also need the customer’s Cognito id token:
Authorization: Bearer <shopper idToken>
ownerId is always the bare Cognito sub from a verified id token.
Do not invent it. Unsigned or forged tokens are rejected.
Shoppers sign up and sign in against the platform's shopper pool (email
and password, a six-digit code by email). Your app talks to Cognito
directly with the pool's public app client id, which we give you with your
key; GET /shop does exactly this in about fifty lines of JavaScript.
Baskets and orders belong to your key and that shopper together — the
same shopper signed in through another partner has a different basket and
sees none of your orders.
Quickstart — GraphQL
curl -sS https://developers.villamarket.ai/graphql \
-H 'Content-Type: application/json' \
-H 'X-Partner-Key: YOUR_KEY' \
-d '{
"query": "query($cpr: ID!, $b: ID!) { product(cprcode: $cpr, branchCode: $b) { cprcode nameEn price { amount currency source } inventory { sellableQty listable } } }",
"variables": { "cpr": "141660", "b": "1000" }
}'
Nested price and inventory are the join win: one client query, branch-
correct numbers. Never display a total that did not come from quote.
Checkout — basket, total, order, payment link
# Where to shop
{ branches { branchCode name } categories { name count } }
query { fulfilmentResolve(input: { lat: 13.7205, lon: 100.5690 }) { branchCode name distanceKm } }
# Browse at a branch: what Villa Market's own online shop shows at that branch (onlineOnly, default true),
# with that branch's prices. A product Villa's own shop hides at a branch is not listed there.
query { products(filter: { query: "brie", pricedOnly: true }, branchCode: "1030", first: 24) {
totalCount pageInfo { hasNextPage endCursor } nodes { cprcode nameEn imageUrl price { amount } } } }
# Basket (needs the shopper token)
mutation { basketAdd(input: { cprcode: "220772", quantity: 2, branchCode: "1030" }) {
basket { branchCode lines { cprcode quantity product { nameEn } } } userErrors { code message } } }
# The total — Villa's own basket calculator; this number is the bill
mutation { quote(input: { shippingType: "DELIVERY", address: { lat: 13.7205, lon: 100.5690, postcode: "10110" } }) {
quote { grandTotal subTotal deliveryFee totalDiscount lines { cprcode price rowTotal } } userErrors { code message } } }
# Place it — re-priced at this moment; returns the payment link
mutation { orderCreate(input: { shippingType: "PICKUP", specialComment: "test" }) {
order { orderId grandTotal status paymentLink { url expiresAt amount } } userErrors { code message } } }
paymentLink.url opens the whole checkout — items, pickup or delivery,
fees, discounts, total — and takes the payment. Send it to the shopper by
chat, email or a button; …/checkout.json gives your app or agent the same
data. A link is a bearer credential for paying that one order, expires
after 24 hours, and is refused if the order's total no longer matches.
paymentLinkCreate(orderId) issues a fresh one for an unpaid order.
Before a payment is accepted the basket is priced again; a changed total
is refused (PRICE_CHANGED) and the shopper places the order again.
Errors come back in userErrors with a stable code: UNAUTHENTICATED,
EMPTY_BASKET, INVALID_CPRCODE, INVALID_QUANTITY, ADDRESS_REQUIRED
(delivery without coordinates), PRICE_UNAVAILABLE (a product the branch
does not price — remove it or switch branch), NOT_SOLD_AT_BRANCH (Villa's
shop does not sell that product at the basket's branch), NOT_FOUND, ALREADY_PAID.
Quickstart — MCP
The server is https://agent.villamarket.ai/mcp/ (also on this host at /mcp/),
Streamable HTTP. Identity comes from the connection's headers. The shopper tools
still list an optional id_token argument for older clients. Leave it empty
and send the headers; it will be removed:
- Individuals:
Authorization: Bearer vpk_…— a personal key made athttps://agent.villamarket.ai(Connect your AI). Revocable there. - Merchants:
X-Partner-Key: YOUR_KEYplus the shopper'sAuthorization: Bearer <idToken>(a merchant's server usually holds one connection per shopper session).
Cursor (.cursor/mcp.json):
{
"mcpServers": {
"villa": {
"url": "https://agent.villamarket.ai/mcp/",
"headers": { "Authorization": "Bearer vpk_YOUR_PERSONAL_KEY" }
}
}
}
Claude Code: claude mcp add --transport http villa https://agent.villamarket.ai/mcp/ --header "Authorization: Bearer vpk_…".
ChatGPT and Claude apps connect with OAuth; Villa's OAuth sign-in is not live yet.
Discovery for agents: https://agent.villamarket.ai/llms.txt and
https://agent.villamarket.ai/.well-known/mcp.json. Every tool carries a
title and a read-only or destructive hint; create_order and start_payment
are destructive, so clients ask the shopper before running them.
Tools mirror GraphQL: search_products, get_product, get_inventory,
list_branches, resolve_fulfilment, list_categories, list_products,
get_basket, add_basket_line, set_basket_quantity, empty_basket,
create_quote, apply_coupon, create_order, create_payment_link,
start_payment, get_order, list_orders.
Resources: villa://schema, villa://docs.
Merchant royalty
Each merchant key carries a royalty rate. Every order placed with your key records it; it accrues when the shopper pays:
royalty = rate × (grandTotal − delivery fee after discounts − express shipping)
PENDING (placed, unpaid) → ACCRUED (paid)
{ merchantEarnings { royaltyRate orders paidOrders paidSales royaltyAccrued royaltyPending
recent { orderId createdAt grandTotal paid royalty { rate base amount status } } } }
merchantEarnings needs only your merchant key and returns no shopper identity,
names or addresses. Every Order also carries royalty { rate base amount status }.
Settlement of accrued royalty is monthly and outside the sandbox.
Hosted agent (optional)
POST /agent/chat runs one turn of Villa's shopping assistant for your
shopper, in your brand: X-Partner-Key + the shopper's Authorization: Bearer
<idToken>, body {"message", "history": [{"role": "user"|"assistant", "content"}],
"branchCode", "location": {"lat", "lon"}}. It answers {reply, cards, activity,
usage}; cards are products, basket, total, an order to confirm, a payment link.
The assistant never places an order — your page shows the confirm card and
calls orderCreate on the shopper's click. Limits per shopper and per merchant
per day come back as HTTP 429 with a code; GET /agent/status says whether
it is switched on. Prompts are never shown your shopper's contact details or
location.
Product catalog (REST)
A read-only copy of the full Villa product catalog, refreshed from the master
product data daily and within about 15 minutes of significant changes. It is
served from an in-memory snapshot, so it is fast and safe to poll; use it for
browsing, search suggestions, syncing your own catalog, and feeding agents.
Branch-specific price and stock are not here — use GraphQL product
for those at checkout time.
| Endpoint | Purpose |
|---|---|
GET /v1/products?lang=en&page=1&size=50 |
Paginated list (size ≤ 200). Filters: q= (name, barcode, keywords), category= (any online/villa category level), active=true, fields=cprcode,name,ba_nprice to trim the payload |
GET /v1/products/{cprcode}?lang=en |
One product |
GET /v1/products/manifest |
Snapshot version, build time, row count, file hashes — poll this to know when the catalog changed |
GET /v1/products/download?lang=en |
302 to a five-minute link for the whole catalog as an Apache Feather file (lang=en, th, or all) — the fastest way to sync everything |
langselects the language projection:en(English names and categories) orth(Thai).name,online_category_l1..3,pr_keyword,pr_countrychange with it; identifiers do not.- Price: read
ba_nprice(the base normal price in THB). ThesellingPricefield is carried from the source data but is not maintained there — treat it as absent. Never showba_npriceas the bill; the quote'sgrandTotalis the bill. pr_active,master_online,avail_nationwidetell you whether an item is sellable online;avail_storemaps branch codes to visibility.
Product search
Ranked search over the same catalog, on https://search.api.villamarket.ai.
Hits are identity only (name, barcode, category, image) — never price or
stock. GraphQL search / searchSuggest and MCP search_products use this
engine. Walk the full catalog with /v1/products, not by paging search.
| Endpoint | Purpose |
|---|---|
GET https://search.api.villamarket.ai/v1/search?q=&lang=en&limit=20 |
Hybrid rank (limit ≤ 50). Optional category=, active= |
GET https://search.api.villamarket.ai/v1/suggest?q=&lang=en&limit=10 |
Prefix names and barcode (limit ≤ 30) |
GET https://search.api.villamarket.ai/v1/manifest |
Index version and the catalog snapshot it was built from |
Same X-Partner-Key as the rest of this API. lang is en or th.
- Responses carry
ETagandCache-Control: private, max-age=300, stale-while-revalidate=600— cache them in your own service or app, not in a shared proxy, and sendIf-None-Matchto get a304. Theversionfield in every response identifies the snapshot. X-Partner-Keyis required on every call.
curl -s -H "X-Partner-Key: $KEY" "https://developers.villamarket.ai/v1/products?lang=en&q=cheese&size=5&fields=cprcode,name,ba_nprice,pr_active"
Checkout rules (non-negotiable)
quote.grandTotalis the bill. Never sum line prices in your app.orderCreateprices the order again on our side; you never send a total.orderCreateomits payment. The order carriespaymentLink; payment happens there (paymentStartreturns the same link asredirectUrl). Card PAN never touches this API.- Stock is not reserved at basket-add or at order create.
- Until production go-live every order is a sandbox order:
testis added tospecialComment, nothing is sent for fulfilment, and the payment link's provider issandbox(it records the payment; no money moves).
Discount field contract (coupons / shipping):
https://knowledge.villamarket.ai/reference/discount-fields
Identity keys
| Key | Meaning |
|---|---|
cprcode |
Product identity everywhere |
branchCode |
Fulfilment branch — price authority |
basketId |
Server-side basket, one per (your key, shopper) |
orderId |
Minted by us at orderCreate, e.g. VC260923-3F9A1C07 |
Order state: status is PLACED; payment is payment.isPaid /
payment.status (UNPAID → PAID).
Sandbox vs production
| Sandbox | Production | |
|---|---|---|
| Host | same (developers.villamarket.ai) |
same |
| Keys | sandbox partner key | production partner key |
| Orders | specialComment contains test |
never test in prod |
| Payment | payment link, provider sandbox (records the payment, no money moves) |
payment link, production provider via Villa |
Live money requires: partner key issued, Cognito app client for your redirect URIs, fulfilment branch resolve confirmed, payment + order webhooks registered.
Webhooks (partner → you)
Not available yet. POST /webhooks/register answers 501. Until webhooks
are durable (a stored registration, HMAC-signed deliveries, retries), poll
order(orderId) or orders for payment and status changes.
When they arrive, events will look like this, signed with
X-Villa-Partner-Signature: sha256=<hex>:
{
"type": "payment.paid | order.status",
"orderId": "…",
"partnerId": "…",
"payload": {},
"ts": 1710000000
}
Go-live checklist
- [ ] Partner key issued;
orderSourceregistered - [ ] Cognito client + redirect URIs for your shop domain
- [ ] Fulfilment resolve tested for your delivery postcodes
- [ ] Quote → order →
paymentStartsandbox closed - [ ] Webhook URL verified (HMAC)
- [ ] Production key rotated; no
testinspecialComment
Support
Engineering vault (public contracts): https://knowledge.villamarket.ai
Ask for a partner key: contact Villa Market AI / partner onboarding.