● Sandbox openGraphQL · REST · MCPv0.1.0

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.


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 Demo shop built only on this API — sign up, shop, check out
GET /health Liveness

Auth

Every GraphQL and MCP call (except public docs) needs:

X-Partner-Key: <key we issue you>

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.

# 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 sells online (onlineOnly, default true), priced at that branch
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_FOUND, ALREADY_PAID.


Quickstart — MCP (Cursor)

Add to .cursor/mcp.json:

{
  "mcpServers": {
    "villa-partner": {
      "url": "https://developers.villamarket.ai/mcp",
      "headers": {
        "X-Partner-Key": "YOUR_KEY"
      }
    }
  }
}

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.


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

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.

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)

  1. quote.grandTotal is the bill. Never sum line prices in your app. orderCreate prices the order again on our side; you never send a total.
  2. orderCreate omits payment. The order carries paymentLink; payment happens there (paymentStart returns the same link as redirectUrl). Card PAN never touches this API.
  3. Stock is not reserved at basket-add or at order create.
  4. Until production go-live every order is a sandbox order: test is added to specialComment, nothing is sent for fulfilment, and the payment link's provider is sandbox (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 (UNPAIDPAID).


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)

Register a HTTPS endpoint. We POST HMAC-signed events when payment settles or order status moves (once Villa feeds us those events).

POST https://your.example/webhooks/villa-partner
X-Villa-Partner-Signature: sha256=<hex>
Content-Type: application/json
{
  "type": "payment.paid | order.status",
  "orderId": "…",
  "partnerId": "…",
  "payload": {},
  "ts": 1710000000
}

Until Villa HMAC webhooks exist, we fan out from sanctioned order polls.

Inbound (Villa → us, for our adapter):

POST https://developers.villamarket.ai/webhooks/villa/payment
POST https://developers.villamarket.ai/webhooks/villa/order-status

Go-live checklist


Support

Engineering vault (public contracts): https://knowledge.villamarket.ai
Ask for a partner key: contact Villa Market AI / partner onboarding.