This reference is for developers integrating the API into a product. It assumes you can make an HTTPS request and read JSON; it does not assume any machine-learning background. It covers every public endpoint, parameter, response field, and error code.
It does not cover pricing or what the product is for — see the TasteRay home page for that — and you don't need to read it to try the API. To send a request without writing any code, open the playground, which runs real requests without a key.
The essentials, before anything else:
| Setting | Value |
|---|---|
| Base URL | Valuehttps://api.tasteray.com |
| Authentication | ValueX-API-Key header |
| Content type | Valueapplication/json |
| Latest release | Value0.7.0 — see Changelog |
Before you begin#
You need three things:
- An API key. Get one at api.tasteray.com/generate-key. It takes an email address and nothing else.
- A server to call from. An API key grants access to your whole quota, so keep it on a server you control. Never ship it in a browser bundle or a mobile app.
- Something to describe. The API needs a vertical (
movies,restaurants,headphones— any noun works) and either a list of preferences or a paragraph describing the person.
The API is stateless. It stores no user profiles and no sessions, so every request carries the full context it needs. That makes requests independent and safe to retry, and it means you decide what user data leaves your system.
Make your first request#
This procedure returns three movie recommendations. It takes about a minute.
Get an API key at api.tasteray.com/generate-key. Copy it — the key is shown once and cannot be retrieved later.
Store the key in your shell:
export TASTERAY_API_KEY=API_KEYReplace
API_KEYwith the key you copied. Keys begin withreco_live_.Send a recommendation request:
curl -X POST https://api.tasteray.com/v1/recommend \ -H "Content-Type: application/json" \ -H "X-API-Key: $TASTERAY_API_KEY" \ -d '{ "vertical": "movies", "context": { "preferences": ["sci-fi", "slow-burn", "practical effects"] }, "options": { "count": 3 } }'Confirm that the response has status
200and arecommendationsarray with three entries. Each entry carries anitem, anexplanationwith awhy_matchsentence, and aconfidencescore between 0 and 1.
A request that uses web grounding — the default — takes several seconds, because the model searches the web before it answers. See Grounding and speed for the trade-off between latency and freshness.
What's next:
- To send a specific list of items and have them ranked instead of generated, see Rank items you already have.
- To ask about one item in depth, see POST /v1/explain.
- To build a personalized feed, see Build a "For You" surface.
Authentication#
Every endpoint except GET /v1/health requires an API key in the X-API-Key header:
POST /v1/recommend HTTP/1.1
Host: api.tasteray.com
Content-Type: application/json
X-API-Key: API_KEYReplace API_KEY with your key. Self-serve keys are live keys: the prefix reco_live_ followed by 48 hexadecimal characters.
Keys are hashed with SHA-256 before storage, so the plaintext key exists only in your copy of it. A key that is unknown or revoked returns 401 with the code INVALID_API_KEY; a request with no header at all returns 401 with the code MISSING_API_KEY.
The API sends Access-Control-Allow-Origin: *, so a browser can technically call it directly. Don't. A key in client-side code is a key you have published. Proxy the call through your own backend instead.
Rate limits#
Each key has a per-minute request limit, enforced with a sliding window, and a monthly request allowance.
The following tiers are available:
| Tier | Requests per minute | Requests per month | How to get it |
|---|---|---|---|
| Free | Requests per minute5 | Requests per month1,000 | How to get itSelf-serve at /generate-key |
| Basic | Requests per minute50 | Requests per month10,000 | How to get itContact hello@tasteray.com |
| Pro | Requests per minute200 | Requests per month100,000 | How to get itContact hello@tasteray.com |
| Enterprise | Requests per minuteCustom | Requests per monthCustom | How to get itContact hello@tasteray.com |
Every response from /v1/recommend and /v1/explain carries the current window's budget in three headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | MeaningRequests allowed per minute on this key |
X-RateLimit-Remaining | MeaningRequests left in the current window |
X-RateLimit-Reset | MeaningWhen the window resets, as a Unix timestamp in milliseconds |
Note:
X-RateLimit-Resetis in milliseconds, not seconds. Divide by 1,000 before passing it to a function that expects Unix seconds.
When a key exceeds its per-minute limit, the API returns 429 with a Retry-After header giving the seconds until the window resets:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded for your tier",
"details": "Limit: 5 requests/minute",
"suggestion": "Wait before retrying or upgrade to a higher tier"
},
"meta": {
"request_id": "req_1772370000000_a1b2c3d4e",
"timestamp": "2026-08-31T12:00:00.000Z"
}
}Honor Retry-After when it's present, and back off exponentially otherwise: wait min(base_delay * 2^attempt, max_delay) between attempts. See Retry failed requests for a worked example.
Endpoints#
The API has four endpoints. Two generate text with a model and are the slow, expensive ones; two are ordinary reads.
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST | Path/v1/recommend | PurposeGenerate or rank recommendations | AuthRequired |
POST | Path/v1/explain | PurposeExplain one item in depth | AuthRequired |
GET | Path/v1/usage | PurposeRead this key's usage and tier | AuthRequired |
GET | Path/v1/health | PurposeCheck service status | AuthNone |
POST /v1/recommend#
Generates recommendations for a vertical, or ranks a list of items you supply. Returns each result with a written explanation and a confidence score.
Request body
{
"vertical": "movies",
"context": {
"preferences": ["sci-fi", "slow-burn"],
"profile": "Watches one film a week, usually late at night, alone.",
"constraints": { "runtime_max": 150, "platforms": ["Netflix"] },
"history": [
{ "item": "Arrival", "rating": "loved", "metadata": { "year": 2016 } }
],
"history_text": "Loved Arrival and Annihilation, bounced off Tenet."
},
"items": [
{ "id": "tt0133093", "name": "The Matrix", "description": "1999 action sci-fi" }
],
"options": {
"count": 3,
"explanation_depth": "detailed",
"include_alternatives": true,
"language": "en_US",
"grounding": true,
"fast": false
}
}Parameters
Only vertical and context are required, and context needs either preferences or profile.
| Parameter | Type | Required | Description |
|---|---|---|---|
vertical | Typestring | RequiredYes | DescriptionWhat kind of thing to recommend. Any non-empty string: movies, restaurants, standing desks. See Verticals. |
context.preferences | Typestring[] | RequiredConditional | DescriptionWhat the person likes, one trait per entry. Required unless context.profile is set. |
context.profile | Typestring | RequiredConditional | DescriptionThe same information as free text, for cases where you have a paragraph rather than a list. Required unless context.preferences is set. |
context.constraints | Typeobject | RequiredNo | DescriptionHard limits as key-value pairs: budget, location, runtime, platform. Any keys you like. |
context.history | Typeobject[] | RequiredNo | DescriptionUp to 50 items the person already saw, each with item and rating strings and optional metadata. |
context.history_text | Typestring | RequiredNo | DescriptionThe same history as free text. |
items | Typeobject[] | RequiredNo | DescriptionA list to rank instead of generating from scratch. Each entry needs id and name; description and metadata are optional. See Rank items you already have. |
options.count | Typenumber | RequiredNo | DescriptionHow many recommendations to return, from 1 to 10. Defaults to 3. |
options.explanation_depth | Typestring | RequiredNo | Descriptionbrief or detailed. Defaults to detailed. |
options.include_alternatives | Typeboolean | RequiredNo | DescriptionWhether explanations may name alternatives. Defaults to true. |
options.language | Typestring | RequiredNo | DescriptionLocale for the generated text, such as es_ES or ja_JP. Defaults to en_US. See Response language. |
options.grounding | Typeboolean | RequiredNo | DescriptionWhether the model may search the web before answering. Defaults to true, or to false when you supply items. |
options.fast | Typeboolean | RequiredNo | DescriptionWhether to use the low-latency model. Defaults to false. Turning it on disables grounding. See Grounding and speed. |
options.research | Typeboolean | RequiredNo | DescriptionDeprecated. An older name for grounding, honored only when grounding is absent. Use grounding instead. |
Both history and history_text can be sent together, and so can preferences and profile. When you send both forms, the model reads both.
Response
Returns 200 with a recommendations array and a meta object.
{
"recommendations": [
{
"item": {
"name": "Arrival",
"vertical": "movies",
"type": "film",
"metadata": {
"creator": "Denis Villeneuve",
"year": 2016,
"genre": ["Sci-Fi", "Drama"],
"runtime_minutes": 116,
"platform_availability": ["Max"],
"rating": 7.9
}
},
"explanation": {
"why_match": "A first-contact film that spends its running time on grief and language rather than on spectacle.",
"key_factors": [
"Slow-burn structure — the tension is intellectual, not kinetic.",
"Practical sets and restrained effects work throughout.",
"116 minutes, inside your runtime limit."
],
"potential_concerns": [
"The non-linear structure rewards attention; it is a poor second-screen film."
]
},
"confidence": 0.92,
"metadata": {
"taste_match_score": 92,
"mood_match": "reflective, late-night"
}
}
],
"meta": {
"request_id": "req_1772370000000_a1b2c3d4e",
"timestamp": "2026-08-31T12:00:00.000Z",
"processing_time_ms": 4213,
"prompt_version": "v2.1",
"total_results": 3,
"tool_usage": {
"total_tool_calls": 1,
"tools_used": ["webSearch"],
"tools_called": [
{
"tool_name": "webSearch",
"arguments": { "query": "slow-burn sci-fi films practical effects" },
"timestamp": "2026-08-31T12:00:01.402Z"
}
]
}
}
}For the meaning of each field, see Response objects.
POST /v1/explain#
Explains why one specific item does or does not suit a person. Use it on an item the user tapped, or on a result from /v1/recommend when the reader wants more than a sentence.
Request body
{
"vertical": "headphones",
"item": {
"name": "Sony WH-1000XM5",
"type": "over-ear wireless",
"metadata": { "price": 399 }
},
"context": {
"user_preferences": ["noise cancelling", "long battery", "comfortable for 8 hours"],
"profile": "Commutes 90 minutes a day and works in an open-plan office.",
"constraints": { "budget_max": 400 }
},
"options": {
"depth": "detailed",
"include_alternatives": true,
"language": "en_US",
"grounding": true,
"fast": false
}
}Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
vertical | Typestring | RequiredYes | DescriptionWhat kind of thing the item is. |
item.name | Typestring | RequiredYes | DescriptionThe item to explain. |
item.type | Typestring | RequiredYes | DescriptionIts subtype, such as film, bistro, or over-ear wireless. |
item.metadata | Typeobject | RequiredNo | DescriptionAnything you already know about the item: price, year, location. |
context.user_preferences | Typestring[] | RequiredConditional | DescriptionWhat the person likes. Required unless context.profile is set. Note the name differs from /v1/recommend, which uses context.preferences. |
context.profile | Typestring | RequiredConditional | DescriptionThe same information as free text. Required unless context.user_preferences is set. |
context.constraints | Typeobject | RequiredNo | DescriptionHard limits as key-value pairs. |
options.depth | Typestring | RequiredNo | Descriptionbrief or detailed. Defaults to detailed. Note the name differs from /v1/recommend, which uses options.explanation_depth. |
options.include_alternatives | Typeboolean | RequiredNo | DescriptionWhether to return an alternatives array. Defaults to false. |
options.language | Typestring | RequiredNo | DescriptionLocale for the generated text. Defaults to en_US. |
options.grounding | Typeboolean | RequiredNo | DescriptionWhether the model may search the web. Defaults to true. |
options.fast | Typeboolean | RequiredNo | DescriptionWhether to use the low-latency model. Defaults to false. |
options.research | Typeboolean | RequiredNo | DescriptionDeprecated. An older name for grounding. Use grounding instead. |
Note: Two parameter names differ from
/v1/recommend, and mixing them up is the easiest mistake to make here: this endpoint takescontext.user_preferences, notcontext.preferences, andoptions.depth, notoptions.explanation_depth.
Response
Returns 200 with a single explanation object, a confidence score, and meta.
{
"explanation": {
"summary": "The right call for an open-plan office, with one caveat about fit.",
"detailed_reasoning": {
"taste_alignment": [
"Industry-leading cancellation on the low-frequency hum that open-plan offices produce.",
"30 hours of battery covers a full week of your commute between charges."
],
"constraint_satisfaction": [
"$399 sits inside your $400 ceiling."
],
"unique_factors": [
"The clamping force is unusually light, which is what makes eight-hour wear plausible."
]
},
"potential_concerns": [
"Over-ear cups get warm over a long session in a warm room."
],
"alternatives": [
{
"name": "Bose QuietComfort Ultra",
"reason": "Slightly better on voices, which matters more than hum in some offices.",
"key_difference": "Softer cancellation of low frequencies, stronger on speech."
}
]
},
"confidence": 0.95,
"meta": {
"request_id": "req_1772370300000_f5g6h7i8j",
"timestamp": "2026-08-31T12:05:00.000Z",
"processing_time_ms": 3187
}
}alternatives is present only when you set options.include_alternatives to true. meta.prompt_version and meta.total_results do not apply to this endpoint and are absent.
GET /v1/usage#
Returns the usage record and tier for the key in the request header. It reads state and calls no model, so it's fast and doesn't consume the model budget.
curl https://api.tasteray.com/v1/usage \
-H "X-API-Key: $TASTERAY_API_KEY"Returns 200:
{
"period": "current_month",
"start_date": "2026-08-01T00:00:00.000Z",
"end_date": "2026-08-31T00:00:00.000Z",
"usage": {
"requests_made": 2450,
"requests_limit": 10000,
"requests_remaining": 7550,
"percentage_used": 24.5,
"reset_date": "2026-09-01T00:00:00.000Z"
},
"breakdown_by_vertical": { "movies": 1200, "restaurants": 800 },
"breakdown_by_day": [
{ "date": "2026-08-01", "requests": 120 },
{ "date": "2026-08-02", "requests": 135 }
],
"tier": { "name": "Basic", "rate_limit": "50 requests/minute" }
}The per-minute limit in tier.rate_limit is the one the API enforces on every request; see Rate limits. For a live count of the current minute's budget, read the X-RateLimit-* headers on a /v1/recommend or /v1/explain response rather than polling this endpoint.
GET /v1/health#
Reports service status. It needs no API key, so you can wire it into an uptime monitor.
curl https://api.tasteray.com/v1/healthReturns 200 when the service is healthy or degraded, and 503 when it's unhealthy:
{
"status": "healthy",
"version": "1.0.0",
"timestamp": "2026-08-31T12:00:00.000Z",
"checks": {
"ai_service": "ok",
"fast_ai_service": "ok",
"analytics": "ok",
"cache": "ok"
}
}The response fields are:
| Field | Values | Meaning |
|---|---|---|
status | Valueshealthy, degraded, unhealthy | MeaningOverall verdict. unhealthy is the only one that returns 503. |
version | Valuesstring | MeaningThe running service version. |
checks.ai_service | Valuesok, degraded, error | MeaningThe model that serves normal requests. |
checks.fast_ai_service | Valuesok, degraded, error | MeaningThe model that serves fast: true requests. degraded means fast falls back to the normal model. |
checks.analytics | Valuesok, degraded, error | MeaningPrompt management and tracing. |
checks.cache | Valuesok, degraded, error | MeaningThe key-value store holding keys and rate-limit counters. |
Alert on status, not on individual checks. A degraded fast_ai_service still serves every request correctly, but without the low-latency path.
Request context#
context is where you describe the person. Both endpoints accept the same two shapes for that description, and you can mix them.
Structured or free text#
Send a list when you hold tags, facets, or checkbox answers:
{ "preferences": ["Italian", "romantic", "no reservations needed"] }Send a paragraph when you hold something a person wrote or said — an onboarding answer, a chat transcript, a survey box:
{ "profile": "Just moved to Seattle. Cooks most nights but wants somewhere unfussy for a first date. Hates loud rooms." }At least one of the two is required. Send both when you have both, and the model reads both. The same choice applies to history: history takes structured entries, history_text takes a paragraph.
Free text is what lets you skip the transformation layer most recommendation systems demand. If your product already stores a written profile, pass it through.
Constraints#
constraints is an open object. Use whatever keys describe a hard limit, and prefer values a machine could check:
{
"budget_max": 300,
"location": "Seattle, WA",
"runtime_max": 150,
"dietary_restrictions": ["gluten-free"]
}Specific beats vague. {"budget_max": 300} constrains the answer; {"budget": "not too expensive"} mostly doesn't.
History#
history is an array of up to 50 items. Each entry needs item and rating strings, plus optional metadata:
{
"history": [
{ "item": "Inception", "rating": "loved", "metadata": { "year": 2010 } },
{ "item": "Tenet", "rating": "3/10", "metadata": { "finished": false } }
]
}rating is free-form on purpose — loved, 5 stars, 8/10, and bounced off it all work, because whatever scale your product already uses can go in unchanged. A request with more than 50 history entries returns 400.
Recent and significant beats exhaustive. Ten items the person felt strongly about steer the answer better than fifty they merely clicked.
Example contexts by vertical#
The following contexts show the shape in four domains.
Movies:
{
"preferences": ["sci-fi", "thriller", "thought-provoking"],
"constraints": { "runtime_max": 150, "release_year_min": 2010, "platforms": ["Netflix", "Max"] },
"history": [{ "item": "Inception", "rating": "loved", "metadata": { "year": 2010 } }]
}Restaurants:
{
"preferences": ["Italian", "romantic", "authentic"],
"constraints": { "location": "Seattle, WA", "price_range": "$$$", "dietary_restrictions": ["gluten-free options"] },
"history": [{ "item": "Canlis", "rating": "excellent", "metadata": { "occasion": "anniversary" } }]
}Products:
{
"preferences": ["wireless", "noise-cancelling", "long battery"],
"constraints": { "category": "headphones", "budget_max": 300, "brand_preference": ["Sony", "Bose"] },
"history": [{ "item": "Sony WH-1000XM4", "rating": "9/10", "metadata": { "owned_for": "2 years" } }]
}Travel:
{
"preferences": ["beaches", "adventure", "culture"],
"constraints": { "budget_total": 3000, "duration_days": 14, "region": "Southeast Asia", "season": "winter" },
"history": [{ "item": "Bali, Indonesia", "rating": "loved", "metadata": { "visited": "2023" } }]
}Response objects#
Both generating endpoints return the same building blocks. Fields whose value is null are removed before the response is sent, so a field you don't see was not populated.
Recommendation#
Each entry in the recommendations array has this shape:
| Field | Type | Description |
|---|---|---|
item | Typeobject | DescriptionThe recommended thing. See Item. |
explanation | Typeobject | DescriptionWhy it fits. See Explanation. |
confidence | Typenumber | DescriptionHow sure the model is, from 0.0 to 1.0. |
metadata | Typeobject | DescriptionRanking signals. See Recommendation metadata. Absent when empty. |
Item#
| Field | Type | Description |
|---|---|---|
name | Typestring | DescriptionThe item's name. |
vertical | Typestring | DescriptionThe vertical it belongs to, echoing your request. |
type | Typestring | DescriptionIts subtype, such as film, bistro, or over-ear wireless. |
metadata | Typeobject | DescriptionFacts about the item, drawn from a fixed set of fields. Absent when empty. |
item.metadata is a closed set: the model fills in the fields that apply to the vertical and leaves the rest out. Do not expect keys outside this list.
| Field | Type | Applies to |
|---|---|---|
rating | Typenumber | Applies toAny vertical |
location | Typestring | Applies toAny vertical |
verified_available | Typeboolean | Applies toAny vertical |
verified_as_of | Typestring | Applies toAny vertical |
creator | Typestring | Applies toFilms, TV, music, books, games — director, author, artist |
year | Typenumber | Applies toFilms, TV, music, books, games |
genre | Typestring[] | Applies toFilms, TV, music, books, games |
runtime_minutes | Typenumber | Applies toFilms, TV |
platform_availability | Typestring[] | Applies toFilms, TV, music |
review_sources | Typestring[] | Applies toFilms, TV, music, books, games |
cuisine | Typestring | Applies toRestaurants |
price_range | Typestring | Applies toRestaurants |
distance_miles | Typenumber | Applies toRestaurants |
dietary_options | Typestring[] | Applies toRestaurants |
atmosphere | Typestring | Applies toRestaurants |
brand | Typestring | Applies toProducts |
price | Typenumber | Applies toProducts |
key_features | Typestring[] | Applies toProducts |
reviews_count | Typenumber | Applies toProducts |
price_as_of | Typestring | Applies toProducts |
category | Typestring | Applies toProducts |
price_per_night | Typenumber | Applies toHotels |
star_rating | Typenumber | Applies toHotels |
amenities | Typestring[] | Applies toHotels |
room_types | Typestring[] | Applies toHotels |
distance_to_center | Typestring | Applies toHotels |
website | Typestring | Applies toHotels |
phone | Typestring | Applies toHotels |
verified_operating | Typeboolean | Applies toHotels |
best_time_to_visit | Typestring | Applies toDestinations |
estimated_budget | Typenumber | Applies toDestinations |
duration_recommended | Typestring | Applies toDestinations |
activities | Typestring[] | Applies toDestinations |
climate | Typestring | Applies toDestinations |
difficulty_level | Typestring | Applies toDestinations |
The verified_* and *_as_of fields carry the model's own claim about freshness, and they appear when web grounding is on. Treat them as a hint, not as a guarantee: confirm price and availability against your own source before you show them as fact.
Explanation#
/v1/recommend returns a short explanation on every recommendation:
| Field | Type | Description |
|---|---|---|
why_match | Typestring | DescriptionOne or two sentences on the fit. Good as a card subtitle. |
key_factors | Typestring[] | DescriptionThe specific reasons, one per entry. Good as a bulleted detail panel. |
potential_concerns | Typestring[] | DescriptionHonest caveats. May be null. |
/v1/explain returns a longer object instead:
| Field | Type | Description |
|---|---|---|
summary | Typestring | DescriptionThe verdict in one or two sentences. |
detailed_reasoning.taste_alignment | Typestring[] | DescriptionWhere the item matches what the person likes. |
detailed_reasoning.constraint_satisfaction | Typestring[] | DescriptionHow it meets the hard limits. |
detailed_reasoning.unique_factors | Typestring[] | DescriptionWhat sets it apart from near-identical options. |
potential_concerns | Typestring[] | DescriptionHonest caveats. |
alternatives | Typeobject[] | DescriptionOther options, each with name, reason, and key_difference. Present only when options.include_alternatives is true. |
Recommendation metadata#
recommendations[].metadata carries ranking signals, drawn from a fixed set:
| Field | Type | Description |
|---|---|---|
taste_match_score | Typenumber | DescriptionFit as a 0–100 score. The primary ranking signal. |
mood_match | Typestring | DescriptionThe mood the item suits, such as reflective, late-night. |
emotional_appeal | Typestring | DescriptionThe feeling the item is reaching for. |
reasoning | Typestring | DescriptionA compressed note on the ranking decision. |
Meta#
Every successful response carries meta:
| Field | Type | Description |
|---|---|---|
request_id | Typestring | DescriptionUnique per request, in the form req_{timestamp}_{random}. Log it and quote it in support requests. |
timestamp | Typestring | DescriptionISO 8601 time the response was built. |
processing_time_ms | Typenumber | DescriptionServer-side duration, excluding network time. |
prompt_version | Typestring | DescriptionThe prompt template version. /v1/recommend only. |
total_results | Typenumber | DescriptionHow many recommendations came back. /v1/recommend only. |
tool_usage | Typeobject | DescriptionWhat the model searched for. Present only when it used a tool. See Tool usage. |
Tool usage#
When grounding is on and the model chooses to search, meta.tool_usage reports what it looked up:
{
"tool_usage": {
"total_tool_calls": 2,
"tools_used": ["webSearch"],
"tools_called": [
{
"tool_name": "webSearch",
"arguments": { "query": "best Italian restaurants Seattle 2026" },
"timestamp": "2026-08-31T12:00:01.402Z"
},
{
"tool_name": "webSearch",
"arguments": { "query": "gluten-free Italian Capitol Hill Seattle" },
"timestamp": "2026-08-31T12:00:03.918Z"
}
]
}
}| Field | Type | Description |
|---|---|---|
total_tool_calls | Typenumber | DescriptionHow many calls the model made across all steps. |
tools_used | Typestring[] | DescriptionThe distinct tool names used. webSearch is the only tool today. |
tools_called | Typeobject[] | DescriptionOne entry per call, with tool_name, arguments, and timestamp. |
The only argument the model supplies is query; result count and search mode are fixed server-side. The model decides for itself whether to search at all, so a grounded request that answers from the model's own knowledge returns no tool_usage field. That's expected, not a failure.
The whole object is absent when options.grounding is false, when options.fast is true, and when the model chose not to search.
Verticals#
vertical is a free string. The API is not restricted to a fixed catalog: whatever noun you send, the model treats as the category to recommend within. movies, sourdough starters, and B2B analytics vendors are all valid.
These verticals are the well-worn paths, grouped by the kind of thing they describe:
| Group | Verticals |
|---|---|
| Entertainment | Verticalsmovies, tv-series, books, music, podcasts, games, mobile-apps |
| Food and dining | Verticalsrestaurants, cafes, bars, recipes, meal-kits |
| Travel | Verticalshotels, flights, destinations, activities, tours, cruises |
| Products | Verticalselectronics, clothing, furniture, home-goods, beauty, sports |
| Services | Verticalscourses, jobs, real-estate, healthcare, finance, fitness |
| Events | Verticalsconcerts, conferences, sports-events, theater, festivals |
What to put in context, by group#
The vertical changes which preferences and constraints carry weight. The following pairings produce the sharpest results:
| Group | Preferences that work | Constraints that work |
|---|---|---|
| Entertainment | Preferences that workGenre, theme, mood, pacing | Constraints that workRuntime, release year, platform, content rating |
| Food and dining | Preferences that workCuisine, atmosphere, dietary focus | Constraints that workLocation, price range, dietary restrictions, party size |
| Travel | Preferences that workActivity type, atmosphere, cultural interests | Constraints that workBudget, duration, region, season, accessibility |
| Products | Preferences that workFeatures, style, brand affinity | Constraints that workBudget, category, specifications, compatibility |
| Services | Preferences that workSkills to learn, career goals, specializations | Constraints that workBudget, time commitment, location, qualifications |
Options in depth#
Grounding and speed#
Grounding lets the model search the web before it answers. It's on by default for both generating endpoints, except when you send items to /v1/recommend — ranking a list you supplied needs no fresh facts.
Grounding is the right default when the answer depends on the world as it is now: what's showing this week, what a restaurant charges, whether a product is still sold. Turn it off when your catalog is the whole truth, or when you need the response faster.
fast switches to a lower-latency model. The trade is real and worth stating plainly:
| Behavior | Default | "fast": true |
|---|---|---|
| Web grounding | DefaultAvailable | `"fast": true`Not available |
meta.tool_usage | DefaultPresent when the model searches | `"fast": true`Never present |
| Latency | DefaultHigher — a web search runs before the answer | `"fast": true`Lower — no search, smaller model |
| Best for | DefaultA considered answer with current facts | `"fast": true`Typeahead, previews, bulk re-ranking |
Setting fast to true and grounding to true in the same request is not an error, but grounding is ignored — the fast path has no web access.
Response language#
options.language takes a locale code and sets the language of every generated string: why_match, key_factors, summary, and the rest.
curl -X POST https://api.tasteray.com/v1/recommend \
-H "Content-Type: application/json" \
-H "X-API-Key: $TASTERAY_API_KEY" \
-d '{
"vertical": "travel",
"context": { "preferences": ["temples", "nature", "hot springs"] },
"options": { "count": 5, "language": "ja_JP" }
}'Any valid locale code works — en_US, es_ES, fr_FR, de_DE, ja_JP, zh_CN, pt_BR, it_IT. Error messages stay in English regardless, so you can log and match on them without a locale-aware parser.
Recipes#
Rank items you already have#
Send your catalog in items and the API ranks that list instead of inventing one. Every returned item comes from what you sent, which keeps recommendations inside your inventory.
options.count caps at 10 on this endpoint as on any other, so rank in batches:
// categoryProducts: [{ id, name, description }, ...]
async function rankCategory(categoryProducts, user) {
const batch = categoryProducts.slice(0, 10);
// count must be 1 to 10, so an empty category never reaches the API.
if (batch.length === 0) return [];
const res = await fetch('https://api.tasteray.com/v1/recommend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.TASTERAY_API_KEY,
},
body: JSON.stringify({
vertical: 'products',
items: batch,
context: { preferences: user.preferences },
options: { count: batch.length },
}),
});
const { recommendations } = await res.json();
return recommendations;
}options.count must be between 1 and 10, so passing a raw categoryProducts.length returns 400 for an empty category and for any catalog past ten. To personalize a longer list, loop over batches of ten, or rank the first ten and leave the tail in its original order. Grounding also defaults to false whenever you send items, since a list you supplied needs no fresh facts.
Build a "For You" surface#
A "For You" feed is one /v1/recommend call whose result you cache per user and re-rank client-side.
async function buildFeed(user) {
const res = await fetch('https://api.tasteray.com/v1/recommend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.TASTERAY_API_KEY,
},
body: JSON.stringify({
vertical: 'movies',
context: {
preferences: user.preferences,
history: user.recentWatches.slice(0, 20),
},
options: { count: 10, explanation_depth: 'brief' },
}),
});
const { recommendations } = await res.json();
// taste_match_score is 0-100 and confidence is 0.0-1.0, so the fallback is
// scaled before comparing. Mixing the two ranges would sort every
// recommendation without a score below every one that has it.
const rank = (r) => r.metadata?.taste_match_score ?? r.confidence * 100;
return [...recommendations].sort((a, b) => rank(b) - rank(a));
}metadata is omitted when it's empty, so taste_match_score can be absent. confidence is always present, which makes it the fallback — scale it by 100 first, or the items you fell back on all sink to the bottom of the feed.
These fields map onto the parts of a feed card:
| Field | Use it for |
|---|---|
metadata.taste_match_score | Use it forThe ranking signal, 0–100 |
confidence | Use it forA tiebreaker, 0.0–1.0 |
explanation.why_match | Use it forThe one-line reason under the title |
explanation.key_factors | Use it forThe expanded "why this" panel |
metadata.mood_match | Use it forGrouping a row by mood |
Cache the result against the user and their preference set, and invalidate it when their preferences or history change. Recommendations are generated fresh on every call, so an uncached feed spends a request — and several seconds — on every page view.
Retry failed requests#
Retry on 429 and on 5xx. Never retry a 4xx other than 429: the request is malformed, and sending it again produces the same error.
async function requestWithRetry(url, body, maxAttempts = 3) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.TASTERAY_API_KEY,
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') ?? 60);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
throw new Error(`Request failed: ${(await res.json()).error.message}`);
}
throw new Error('Exhausted retries');
}Call the API from Python#
import os
import requests
response = requests.post(
"https://api.tasteray.com/v1/recommend",
headers={
"Content-Type": "application/json",
"X-API-Key": os.environ["TASTERAY_API_KEY"],
},
json={
"vertical": "restaurants",
"context": {
"preferences": ["Italian", "romantic", "authentic"],
"constraints": {"location": "Seattle, WA", "price_range": "$$$"},
},
"options": {"count": 3},
},
timeout=60,
)
response.raise_for_status()
for rec in response.json()["recommendations"]:
print(rec["item"]["name"])
print(f" {rec['explanation']['why_match']}")Set a generous client timeout. A grounded request routinely takes longer than the default timeout of most HTTP clients, and a client that gives up at five seconds reports failures the API never had.
Errors#
Every error uses the same envelope, whatever the status code:
{
"error": {
"code": "INVALID_REQUEST",
"message": "Invalid count option",
"details": "The options.count must be a number between 1 and 10",
"suggestion": "Set count to a value between 1 and 10"
},
"meta": {
"request_id": "req_1772370000000_a1b2c3d4e",
"timestamp": "2026-08-31T12:00:00.000Z"
}
}error.code is the stable field — branch on it. error.message names what went wrong, error.details says which rule was broken, and error.suggestion says what to change; all three are written for a human reading a log, and their wording can change. meta.request_id identifies the request in our traces, so include it in support mail.
Error codes#
| Status | Code | Cause | What to do |
|---|---|---|---|
| 400 | CodeINVALID_REQUEST | CauseThe body isn't valid JSON, a required field is missing, or a value is out of range. error.details names the field. | What to doFix the field named in details. Don't retry unchanged. |
| 401 | CodeMISSING_API_KEY | CauseNo X-API-Key header. | What to doAdd the header. |
| 401 | CodeINVALID_API_KEY | CauseThe key is unknown or revoked. | What to doCheck the key, or mint a new one at /generate-key. |
| 404 | CodeNOT_FOUND | CauseThe path doesn't exist. | What to doCheck the path against Endpoints. |
| 429 | CodeRATE_LIMIT_EXCEEDED | CauseThe key passed its per-minute limit. | What to doWait for Retry-After seconds, then retry. |
| 500 | CodeINTERNAL_ERROR | CauseSomething failed on our side, including a model that failed after its fallback. | What to doRetry with exponential backoff. If it persists, send us the request_id. |
INVALID_REQUEST covers every validation failure, so the code alone doesn't tell you which field is wrong — error.details does. These are the checks that produce it:
verticalis missing, empty, or not a string.contextis missing, or has neitherpreferencesnorprofile.context.historyisn't an array, holds more than 50 entries, or has an entry missingitemorrating.options.countis outside 1 to 10.options.explanation_depthisn'tbriefordetailed.options.languageisn't a non-empty string.options.grounding,options.research, oroptions.fastisn't a boolean.- On
/v1/explain:item.nameoritem.typeis missing, orcontexthas neitheruser_preferencesnorprofile.
Best practices#
Shape the request#
Three to seven preferences is the productive range. Past that, additional traits dilute rather than sharpen, because every trait competes for the same answer. Constraints behave the opposite way: add every one that's genuinely hard, since each removes wrong answers outright.
Match count to the surface. One to three suits a decision moment, three to five a standard result set, five to ten a browsing feed. Asking for ten when you show three spends latency on results nobody reads.
Handle latency#
A grounded request takes several seconds, because it makes real web searches before answering. Design for that rather than around it: generate the recommendation off the request path where you can, cache the result per user, and show a skeleton rather than a spinner when you can't. Where a sub-second answer matters more than current facts, use "fast": true.
Protect the key#
Keep the key on your server, read it from an environment variable or secret manager, and rotate it if it ever reaches a log or a repository. A key is bearer credential for your whole quota — anyone holding it can spend it.
Watch the budget#
Read X-RateLimit-Remaining on responses and throttle before you hit zero, which costs a round trip and a Retry-After wait. Call GET /v1/usage for the monthly picture rather than the per-request one.
Log the request ID#
Store meta.request_id alongside your own request logs. It's the one value that lets us find a specific generation in our traces, and it turns a support thread into a lookup.
Agent skills#
TasteRay publishes two skills on the Agent Skills open standard. Install them in a coding agent to use elicitation and recommendations from your editor.
Install the elicitation skill, which walks a user through a conversation that uncovers mood, constraints, and past favorites before any request is made:
npx skills add tasteray/skills/elicitationInstall the recommendations skill, which calls this API and formats the results with their match scores and explanations:
npx skills add tasteray/skills/recommendationsInstall both:
npx skills add tasteray/skillsThe skills run in Claude Code, Cursor, Copilot, Windsurf, Cline, Aider, and Roo Code. The source is at github.com/tasteray/skills.
How it works#
The API runs on Cloudflare Workers, so requests are served from the edge location nearest the caller and there's no cold start to absorb.
A request passes through five stages:
- Authentication. The key is hashed and looked up; the plaintext is never stored.
- Rate limiting. A sliding one-minute window counts requests against the key's tier.
- Validation. The body is checked field by field, and a failure returns
400before any model is called. - Generation. A prompt template is fetched by version, compiled with your context, and sent to the model, which may call web search. A failure falls back to a second model.
- Response. The model's output is validated against a JSON schema, null fields are stripped, and traces are flushed.
State lives in a key-value store: hashed keys, rate-limit counters on a two-minute expiry, and usage records. The store is eventually consistent, with roughly 60 seconds of propagation, so a burst spread across two edge locations can occasionally clear a per-minute limit it would have hit from one.
Support#
- Documentation: this page, or /llms.txt for the same content as plain Markdown.
- Try it without code: the playground.
- Email: hello@tasteray.com. Include
meta.request_idwhen reporting a specific request.
Changelog#
The format follows Keep a Changelog. Entries that change the API surface are marked; the rest are website and tooling work.
[0.7.0] — 2026-02-02#
Changed
- Rebuilt the landing page around the playground as the activation surface.
- Moved body type to Hanken Grotesk and display type to Epilogue.
- Fixed a mobile overlap between the header and the hero.
[0.6.0] — 2026-01-12#
Added
- Product analytics on the landing page, playground, and key generator.
[0.5.0] — 2025-12-02#
Added
- API:
context.profileandcontext.history_text, which accept free-form text in place of the structuredpreferencesandhistoryarrays.
Changed
- API: Renamed the health check services to
ai_service,fast_ai_service,analytics, andcache. - Renamed the product from "TasteRay External API" to "TasteRay Emotional API".
- Unified the dark theme across every page.
[0.4.1] — 2025-11-24#
Added
- API:
options.fast, which routes a request to a lower-latency model. - API: Automatic fallback to a second model when the first one fails.
[0.4.0] — 2025-11-16#
Added
- API: Server-side control over when the model may call web search.
[0.3.0] — 2025-11-15#
Added
- API: Web search grounding, with the searches reported in
meta.tool_usage. - API:
options.language, which sets the language of every generated string. - API: Schema-validated structured output on both generating endpoints.
- Endpoint tabs in the playground.
[0.2.0] — 2025-11-07#
Added
- The interactive playground.
- Self-serve API key generation.
Changed
- Switched the package manager from npm to Bun.
[0.1.0] — 2025-11-05#
Added
- First release:
POST /v1/recommend,POST /v1/explain,GET /v1/usage, andGET /v1/health. - API key authentication and sliding-window rate limiting.
- Versioned prompt management and request tracing.