Developers
Search API
One call turns what a shopper types, in English or Thai, into a page of Villa Market products: the best match first, then sections, with stock and promotions. This page is for Villa's web team, to put it into villamarket.com.
SEARCH_API_BASErouted: it re-ranks the shop's own search with the model (rerank), and hands a search to the newer engine, v4, which reads occasions, needs and dishes, when the search names a dish or a group, when the shop's search finds nothing, or when the re-ranked page has no good match. /api/meta names the default; ask for rerank or v4 by name to pin one. Stock, prices and promotions are a read-only snapshot of Villa Market's data: /api/meta says when it was taken.
Quick start
Send the search as typed. Ask for the simple view: the storefront's own cards and sections.
const res = await fetch("SEARCH_API_BASE/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: "turkey", view: "simple" }),
});
if (!res.ok) throw new Error(`search: ${res.status}`);
const result = await res.json();
console.log(result.page[0].name); // "Oven Roasted Turkey"curl -s SEARCH_API_BASE/api/search \
-H 'Content-Type: application/json' \
-d '{"q":"turkey","view":"simple"}'The answer (a real one, trimmed to two products):
{
"query": "turkey",
"engine": "rerank",
"page": [
{
"cprcode": "257624",
"name": "Oven Roasted Turkey",
"name_th": "เนื้อไก่งวงบดอัดก้อนปรุงรส",
"brand": "SPAM",
"size": "340g",
"price": 269,
"list_price": 299,
"promo": null,
"stock": 74,
"image": "https://d1vl5j0v241n75.cloudfront.net/0257624-1",
"country": "US",
"shelf": "Grocery › Cans & Packets › Meals & Meats",
"group": "Cans & Packets › Meals & Meats",
"tier": "STRONG",
"band": 0
},
{
"cprcode": "136873",
"name": "Roasted Carved Turkey",
"brand": "Hungry-Man",
"size": "454g",
"price": 310,
"list_price": null,
"promo": null,
"stock": 9,
"image": "https://d1vl5j0v241n75.cloudfront.net/0136873",
"country": "US",
"shelf": "Frozen Food › Ready Meals › Chicken",
"group": "Ready Meals › Chicken",
"tier": "STRONG",
"band": 0
}
],
"groups": [
{
"key": "Cans & Packets › Meals & Meats",
"count": 1
},
{
"key": "Ready Meals › Chicken",
"count": 1
},
{
"key": "Pies & Sausage Rolls › Pies",
"count": 2
}
],
"understood": {
"kind": "product",
"label": "turkey",
"food": true,
"country": null,
"intent": [],
"alternative": {
"kind": "origin",
"q": "products from turkey",
"country_en": "Turkey",
"country_th": "ตุรกี"
},
"did_you_mean": null,
"suggestions": [
"products from turkey",
"Breadcrumbs & Stuffing"
]
},
"jev": {
"requests": 2,
"cached": 2,
"input_tokens": 7863,
"live_requests": 0,
"live_input_tokens": 0,
"limit": null
},
"timing": {
"total_ms": 51
}
}Show page in the order it comes, in the sections of groups, under the line understood describes. The integration guide covers each part, or the widget renders it all in one line.
POST /api/search
A search. The body is JSON, 20 KB at most. Only q is required.
| Field | Type | What it does |
|---|---|---|
q required | string | The search as the shopper typed it, in English or Thai: a product, a brand, a shelf, a dish, an occasion or a need. Up to 120 characters. |
view | "simple" | "advanced" | "simple" is the storefront's view: the fields below, and the lower model limits. Send it. Without it you get "advanced": the demo's research view (roles, a dish, a shopper profile), with twice the model allowance. |
engine | "routed" | "rerank" | "v4" | Leave it out for the site's default (default_engine). "rerank": the shop's own search, graded and ordered by the model. "v4": the newer engine, which reads occasions, needs and dishes. "routed": rerank, and v4 for a dish or a group, when the shop's search finds nothing or fails, or when rerank grades nothing as a good match on its first screen; the response's route says which. |
phase | "quick" | An instant answer with no model calls: word matches, or the shop's own order. Send it together with the full request to show something at once, then replace it. It has its own rate allowance. |
compare | boolean | Also returns today: the same search as villamarket.com shows it now, for side-by-side checks. |
context | object | Advanced view: what the shop knows about the shopper (session, behaviour, profile). 8 KB at most. |
There is no lang field: every product comes with its English and Thai names, and the page picks. Filters (in stock, on promotion) are the page's too, over the products returned.
The response
| Field | What it is |
|---|---|
query | The search, as read. |
engine | The engine that answered: rerank, v4 or jev. A routed search names the engine it went to. |
route | routed only: { engine, why }. engine is rerank or v4; why is null for rerank, or one of "dish", "group", "origin" (an origin stated outright: "products from Japan"), "shop-empty", "shop-error", "attribute-missing" (the shop's list has none with the attribute asked for: "fresh prawn"), "rerank-no-good" and "supplies-only" (a search that is nothing but an occasion, such as "birthday" or วันเกิด, whose re-ranked first screen is 80% or more supplies, answered by v4 when it builds a page for that occasion: "birthday"; otherwise rerank's page stays, with "supplies-only; v4 no occasion page"), with "; v4 stopped" added when a limit kept v4 from finishing, so rerank's page stays. A quick answer carries the route too, decided without grading, so it never says "rerank-no-good" or "supplies-only"; the full answer can. |
page | The products, best first, usually up to 30. Each is a product card (below). |
groups | The page's sections, in order: { key, count }. A card's group is one of these keys. |
group_th | Thai names for sections that are smart groups (v4): { "BBQ": "บาร์บีคิว" }. |
understood | How the search was read: kind (product, intent or origin), label, intent ([{ en, th }]), did_you_mean, alternative (the other reading of an ambiguous word: "products from turkey") and suggestions for a thin or empty page. |
notice | Present only when the model was not asked (a limit, or the model unavailable): "jev_unavailable" (the shop's own order) or "jev_unavailable_words" (word matches). The page is still usable; say so in one line. |
jev | What the search cost: requests, cached, input_tokens, live_requests, live_input_tokens, and limit when one stopped it. |
timing | { total_ms } on the server. |
tree | v4 only: the path its decision tree took, node by node. |
today, today_meta | With compare: villamarket.com's own order for the same search, and its count and time. |
A product card
| Field | What it is |
|---|---|
cprcode | Villa's product code (a string): the key for the product page and the cart. |
name, name_th | The English name, and the Thai name when there is one. |
brand, size, country | Brand, pack size ("340g"), and country of origin (ISO code; TH is local). |
price, list_price, promo | The shelf price in baht; the price before a promotion, when there is one; and the promotion's own label. |
stock | Units at the branch. 0 or less is out of stock: keep the card, labelled, after the in-stock ones. |
image | The product photo's URL, or null. |
shelf, group | Villa's category path ("Grocery › Cans & Packets › Meals & Meats"), and the page section it belongs to. |
tier | The model's grade against the search: EXACT, STRONG, RELATED or NOT; null when not graded. |
band | 0 what was asked for, 1 a substitute, 2 not what was asked. Show band 0 first. |
match | "brand" when only the brand matches (Turkey Hill syrup for "turkey"), "pets" for pet products; these come last. |
Other fields (reading, pipeline, counts, context_sent and more) are the demo's diagnostics. They are not part of the contract and may change.
GET /api/meta
The service's settings, for a status line or a health check.
| Field | What it is |
|---|---|
default_engine | The engine a search gets without engine: routed, rerank or v4. |
jev_budget, jev_tokens_today, jev_day | The site's model budget for the day, what is used of it, and the day (UTC). |
cost | The public prices behind pricing: the model's price per million input tokens, AWS per search, and the measured tokens of a product and an occasion search. |
snapshot | When the product data was read from Villa Market. |
branch | The branch whose stock and prices the answers use. |
Errors
Errors are JSON: { "error": "…" }, a sentence safe to log (never to show as it is: show your own words).
| Status | When | What to do |
|---|---|---|
400 | The body is not JSON, q is empty, or context is over 8 KB. | Fix the request. |
401 | An X-Api-Key that is unknown or revoked. | Check the key; no key means the visitor's limits. |
413 | The body is over 20 KB. | Send less context. |
429 | Too many searches this minute. Retry-After: 60. | Show the storefront's own search, and try again after a minute. |
503 | The model is unavailable for a search that has no plain answer, the service is starting, or an API key could not be checked. | Fall back to the storefront's own search. |
500 | A fault on our side. | Fall back; it is logged. |
Pricing and limits
A search costs what the model reads for it, plus a small share of the servers. Nothing runs between searches: there is no search cluster or cache server to pay for.
cost of a search ≈ live model input tokens × $0.042 / 1,000,000 + $0.00003 (AWS)| Search | Live model tokens | Cost |
|---|---|---|
| A repeat within 30 days | 0 | ≈ 0.003¢ |
| A product search | about 6,200 | ≈ 0.03¢ |
| An occasion, a need or a recipe | about 13,000 | ≈ 0.06¢ |
| 1,000 searches (the test set's mix, none cached) | about 7.9 million | ≈ $0.36 |
- Repeats cost no model calls for 30 days. The model's answers are kept, so a popular search pays the model once a month; each repeat still costs the servers' share, ≈ 0.003¢. The table counts every search as new, so the real average is lower.
- Some answers need no model calls at all: the
quickphase, and the shop's own order when a limit is reached (with anotice). - Every search is capped: a
simplesearch stops asking the model at 3 requests or 20,000 tokens (about 0.09¢), whichever comes first. - Prices: the model input price ($42 per billion input tokens; no output price is listed); AWS at public prices, approximate.
/api/metacarries them ascost, withjev_tokens_today.
Limits
The model is asked only when an answer is not already known. The limits below count only live model requests.
| Limit | Value | When it is reached |
|---|---|---|
| Searches a minute, per visitor | 30 (the quick phase has its own 30) | 429 for the rest of the minute. |
| Searches with the model a day, per visitor | 100 | Answers without the model, with a notice. |
| Model use per search | 3 requests, 20,000 input tokens (simple) | The search answers with what it has. |
| Model use a day, the whole site | jev_budget in /api/meta | Every search answers without the model, with a notice, until the day ends (00:00 UTC, 07:00 in Bangkok). |
A request with an API key has its client's own rate and daily model tokens instead of the visitor's: see access.
Guide
Integrate villamarket.com
Where to call it
- The search results page first. Call it when a shopper submits a search, from the page or from your server (with an API key).
- Type-ahead later. The
quickphase answers in milliseconds with no model calls, and suits suggestions as the shopper types; the full search runs when they submit. - Keep the storefront's own search as the fallback. On a
429, a5xxor a timeout (5 seconds is plenty), show today's search, never an empty page.
Render the page
- The understood line.
understood.label, with the count in stock. If there isdid_you_meanor analternative, one link each: a click is a new search. - Filters. Chips over the products returned: in stock, on promotion (
promoorlist_price), local (countryTH). Hide a chip that would not change the page. - The best match. One larger card: the first in-stock card of the best band among the first three. Never an out-of-stock card or a substitute.
- Sections. Group
pagebygroup, in the order ofgroups. A section of one product can join a "Top matches" row. Keep the page's order inside each. - Each card. Photo, name, brand and size, price; a struck-through
list_pricewith a promotion; "Out of stock" greyed but readable. - A notice. When
noticeis present, one quiet line: "Showing the shop's usual results."
This is the demo's Simple view. The widget is that view in one element, and its script (villa-search.js, not minified) is the reference: layout() makes the sections, renderSmart() the page.
English and Thai
A shopper can search in either language on either page. Show name_th on a Thai page when it is there, else name. Section names are Villa's English category names: translate them with group_th first, then your own category table.
Caching
Answers carry Cache-Control: no-store, because stock and the day's budget change. A server may keep an answer a minute per search. Repeats are cheap anyway: the service keeps the model's answers for 30 days.
Analytics worth sending
| Event | With | Why |
|---|---|---|
search | query, engine, products shown, notice, time | Volume, speed, and how often the model was not asked. |
search_click | cprcode, position, section, band | Whether the first result is the one people take. |
search_add_to_cart | cprcode, position | The measure that matters. |
search_empty | query | The searches to fix next. |
search_refine | "Did you mean", an alternative, a suggestion or a filter | Where the first reading missed. |
Guide
The <villa-search> widget
The whole results page in one element: the understood line, filters, the best match and sections, in English or Thai, on a phone or a desktop. It is the demo's Simple view, in a shadow root, so your CSS and its CSS never meet.
<script src="SEARCH_API_BASE/villa-search.js" defer></script>
<villa-search q="turkey" lang="th"></villa-search>It calls the API from the shopper's browser, so the page's site must be on the CORS list. It never takes an API key.
Attributes
| Attribute | What it does |
|---|---|
q | The search. Changing it searches again; so does el.search("…"). |
lang | en (default) or th. |
engine | Leave it out for the default; routed, rerank or v4 to pin one. |
product-url | Where a product card leads, with {cprcode} in it: /product/{cprcode}. |
api | The API's address; by default, where the script came from. |
Events
| Event | Detail |
|---|---|
villa-search:results | The API's answer, as above. |
villa-search:select | { cprcode, name, product } when a shopper opens a product. Call preventDefault() to stop product-url and route it yourself. |
villa-search:query | { q } when a shopper takes "Did you mean" or a suggestion: update your search box. |
villa-search:error | { status, message }. The widget shows "try again"; show your own search if you prefer. |
villa-search:refined-ready | { q }. On a slow search the widget first shows quick results; if the shopper has scrolled or tapped by the time the full answer comes, it does not swap under their finger but offers "Refined results ready · Show" at the top of its results. Offer it in your own header too if you like, and call el.showRefined(); villa-search:results follows. Give the element a scroll-margin-top the height of your sticky header. |
const results = document.querySelector("villa-search");
results.addEventListener("villa-search:select", (e) => {
e.preventDefault();
router.push(`/product/${e.detail.cprcode}`);
});
searchBox.addEventListener("submit", (e) => {
e.preventDefault();
results.search(searchBox.q.value);
});Style it
Set these on the element or any parent. Each has the demo's colour as its default.
--vs-font--vs-ink--vs-muted--vs-card--vs-paper--vs-line--vs-accent--vs-accent-2--vs-promo--vs-notice--vs-radius--vs-shadow
villa-search {
--vs-font: "Kanit", system-ui, sans-serif;
--vs-accent: #0b6e4f;
--vs-promo: #ffcc00;
--vs-radius: 8px;
}Tool
Playground
A real search against this service: the widget on the left, the JSON on the right. Each run is one search (two requests: quick, then full).
Search above, or pick an example, to see the widget here.
Search to see the answer.
Production
Access and API keys
From a browser: Villa's sites
Pages on these sites may call the API directly. The browser sends its Origin, and the API answers with Access-Control-Allow-Origin for these only:
https://shop.villamarket.comhttps://dev.villamarket.nethttps://villamarket.comhttps://www.villamarket.com
Each shopper counts as a visitor for the limits. Another site, a new test host for instance, is a setting: ask for it.
From a server: an API key
A server that calls on behalf of many shoppers sends a key, and gets its own rate and daily model tokens instead of one visitor's. The site's daily model budget still applies to everyone.
curl -s SEARCH_API_BASE/api/search \
-H 'Content-Type: application/json' \
-H "X-Api-Key: $VILLA_SEARCH_API_KEY" \
-d '{"q":"turkey","view":"simple"}'- Keys are issued by hand, one per client, with its searches a minute and model tokens a day. Ask the team that runs this service.
- A key lives on your server: in its secret store, never in a web page, a repository or a chat. Only a hash of it is kept here.
- An unknown or revoked key gets
401. A key that leaks is revoked, and a new one issued.