Public API: Recommendations
The recommendations API has two parts under one resource:
- Recipes: a recipe picks an algorithm (
BESTSELLERS,TRENDING,NEW_ARRIVALS,VIEWED_TOGETHER,BOUGHT_TOGETHER,SIMILAR) and its tuning knobs. Each recipe owns one engine-managed output dataset that the snippet serves from the edge. - Merchandising rules: per-project (or per-recipe) overrides that boost, bury, pin, or exclude products in a recipe's output.
Both live under a project and authenticate with a service token. The org comes from the token, so every path below carries only {projectId}, not {orgId}. There is no single endpoint for "recommendations": each part has its own base path.
https://app.avsb.cloud/api/v1/projects/{projectId}/recommendations/recipeshttps://app.avsb.cloud/api/v1/projects/{projectId}/recommendations/merchandising-rulesScopes
A scope is a named permission on your token. It controls exactly what the token can read or change.
| Operation | Scope |
|---|---|
| List / get recipes and rules | recommendations:read |
| Create / update / delete recipes and rules | recommendations:write |
Trigger an engine run (recipes/{recipeId}/run) | recommendations:write |
Every /api/v1 token is rate-limited: a scoped token gets 600 reads and 120 writes per minute, and an admin:* token gets 600 requests per minute, reads and writes together. Every write call below also accepts an optional Idempotency-Key header for safe retries. See Conventions for both.
Every recommendations endpoint also needs the integrations_api_export plan feature, on top of its scope. Without it every call refuses with 403 feature_disabled and details.feature: "integrations_api_export", no matter which scope the token holds. See Refusals at 403.
recipes/{recipeId}/run is a compute trigger. It rides the token's write rate-limit tier. On top of that, the engine enforces its own stricter limit: one manual run per recipe per 10 minutes.
List recipes
GET /api/v1/projects/{projectId}/recommendations/recipes: cursor-paginated, newest first, up to 100 per page (default 20). Requires recommendations:read.
curl 'https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes?limit=20' \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch('https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes?limit=20', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data, page } = await res.json()import os, requestsr = requests.get("https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"})data, page = r.json()["data"], r.json()["page"]{ "data": [ { "id": "rec...", "shortId": 3, "projectId": "cmp...", "name": "Homepage bestsellers", "algorithm": "BESTSELLERS", "params": { "windowDays": 28, "topN": 12 }, "fallbackChain": [], "outputDatasetId": "cmd...", "outputDatasetSlug": "homepage-bestsellers-engine", "enabled": true, "lastRunAt": "2026-06-18T02:00:00.000Z", "lastTriggeredAt": null, "lastRunStats": { "status": "success", "rows": 12 }, "lastRunStatus": "succeeded", "lastRunError": null, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}Pass page.nextCursor back as ?cursor= to fetch the next page. Treat the cursor as an opaque string.
Run status
Every recipe carries lastRunStatus and lastRunError, so you can tell whether the last run worked without reading the stats yourself.
lastRunStatus | Meaning |
|---|---|
null | The recipe has never been run. |
queued | A run was triggered and the engine has not reported on it yet. |
running | The engine is working on it (the Similar items pipeline reports its stage while it runs). |
succeeded | The last run finished. If it found too little shopper activity to publish a new list, lastRunStats.status is gathering_data. |
failed | The last run failed. lastRunError says why. |
A run that is still queued 30 minutes after it was triggered turns into failed, with a lastRunError saying the engine did not report back. Trigger it again; if it keeps happening, contact support.
Create a recipe
POST /api/v1/projects/{projectId}/recommendations/recipes: name and algorithm are required. Creating a recipe also provisions its engine output dataset, 201 Created. Requires recommendations:write.
The smallest request that works:
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes \ -X POST -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Homepage bestsellers", "algorithm": "BESTSELLERS" }'const res = await fetch('https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Homepage bestsellers', algorithm: 'BESTSELLERS' }) })const { data } = await res.json()import os, requestsr = requests.post("https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"name": "Homepage bestsellers", "algorithm": "BESTSELLERS"})data = r.json()["data"]algorithm is immutable after creation; pick it once. Leaving out params uses the algorithm's engine defaults (windowDays: 28, topN: 12, minSupport: 3, topK: 12, all clamped 1 to a per-field cap). fallbackChain lets a recipe fall back to another recipe or dataset, up to 4 steps, when it returns no results for a seed.
Adding the optional fields sends a non-default topN and a fallbackChain that falls back to another recipe when this one has no results for a seed:
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes \ -X POST -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Homepage bestsellers", "algorithm": "BESTSELLERS", "params": { "windowDays": 28, "topN": 12 }, "fallbackChain": [{ "kind": "recipe", "id": "rec_fallback123" }] }'{ "data": { "id": "rec...", "shortId": 4, "name": "Homepage bestsellers", "algorithm": "BESTSELLERS", "params": { "windowDays": 28, "topN": 12 }, "fallbackChain": [{ "kind": "recipe", "id": "rec_fallback123" }], "outputDatasetSlug": "homepage-bestsellers-engine", "enabled": false, "lastRunAt": null, "lastTriggeredAt": null, "lastRunStats": null, "lastRunStatus": null, "lastRunError": null, "...": "..." } }A new recipe starts with "enabled": false: nothing serves it until you turn it on with PATCH { "enabled": true } (see below). Turning on a SIMILAR recipe also starts its first run, so its results exist before the next nightly run.
topN caps at 50 (minSupport at 100, windowDays at 365, topK at 50); an out-of-range value is refused before it reaches the database:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "issues": [{ "param": "params.topN", "path": ["params", "topN"], "code": "too_big", "limit": 50, "expected": "number", "message": "Number must be less than or equal to 50" }] }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" } }Get / update / delete a recipe
GET, PATCH, and DELETE on /api/v1/projects/{projectId}/recommendations/recipes/{recipeId}. PATCH updates name, params, fallbackChain, and enabled (not algorithm). Same request shape as Create a recipe above; only the URL and method change per language.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes/<recipeId> \ -X PATCH -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" \ -d '{ "enabled": false }'{ "data": { "id": "rec...", "enabled": false, "...": "..." } }DELETE returns the deleted recipe's id, but refuses with 409 when another recipe's fallback chain still points at this one:
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes/<recipeId> \ -X DELETE -H "Authorization: Bearer avsb_svc_..."{ "data": { "id": "rec..." } }{ "error": { "code": "dataset_state_conflict", "message": "Recipe is referenced in the fallback chain of \"Category page fallback\" and cannot be deleted", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#conflict-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" } }Trigger an engine run
POST /api/v1/projects/{projectId}/recommendations/recipes/{recipeId}/run: enqueues a single engine run. Returns 202 Accepted when the job is forwarded. Requires recommendations:write.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes/<recipeId>/run \ -X POST -H "Authorization: Bearer avsb_svc_..." \ -H "Idempotency-Key: $(uuidgen)"const res = await fetch('https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes/<recipeId>/run', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsr = requests.post("https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/recipes/<recipeId>/run", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"})data = r.json()["data"]{ "data": { "queued": true } }| Status | Meaning |
|---|---|
202 | Run forwarded to the engine (runs asynchronously). The recipe's lastRunStatus reads queued until the engine reports. |
429 | The recipe was triggered within the last 10 minutes. |
502 | The recommendation engine is temporarily unreachable; retry shortly. |
{ "error": { "code": "rate_limited", "message": "Recipe was already triggered recently. Try again in 480 seconds.", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#rate-limit-headers", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" } }{ "error": { "code": "internal_error", "message": "Recommendation engine is unavailable, try again shortly", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#server-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" } }Run-completion webhook
The run itself is asynchronous. A webhook is an automatic HTTP request AvsB sends to your server when something happens, instead of you having to poll for it. When the engine finishes a run, AvsB sends one: recommendation.run.completed on success, or commerce.job_failed on failure. A failure event also carries kind: "REC_RUN" and a reason describing what went wrong.
{ "id": "del_clxyz123abc", "event": "recommendation.run.completed", "timestamp": "2026-06-18T02:00:04.000Z", "source": { "type": "commerce", "id": "rec...", "name": "Recommendation run: Homepage bestsellers" }, "bypassedGate": false, "kind": "REC_RUN", "projectId": "cmp...", "projectName": "Marketing Site", "link": "https://app.avsb.cloud/projects/cmp.../commerce/recommendations/rec..."}This is the same signing scheme every AvsB webhook destination uses, not one built just for recipes. Every delivery carries an X-AvsB-Signature: sha256=<hex> header, plus X-AvsB-Delivery-Id and X-AvsB-Timestamp. The signature is an HMAC-SHA256 of {deliveryId}.{timestamp}.{rawBody}, not the raw body alone, keyed by the destination's signing secret. Read Verifying signatures for the full recipe, including the 5-minute replay-tolerance check and a drop-in Node.js and Python example.
Merchandising rules
Merchandising rules re-rank a recipe's output. A rule has a kind and a match selector (skus, category, or a where field test):
| Kind | Effect | Requires |
|---|---|---|
boost | Rank matched items higher | weight (> 1, ≤ 1000) |
bury | Rank matched items lower | weight (> 0, < 1) |
pin | Fix matched items to a slot | position (0–100) |
exclude | Remove matched items | None |
List merchandising rules
GET /api/v1/projects/{projectId}/recommendations/merchandising-rules: cursor-paginated, newest first, up to 100 per page (default 20). Pass ?recipeId=<recipeId> to narrow to one recipe's rules plus the project-wide rules. Requires recommendations:read.
curl 'https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules?limit=20' \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch('https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules?limit=20', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data, page } = await res.json()import os, requestsr = requests.get("https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"})data, page = r.json()["data"], r.json()["page"]{ "data": [ { "id": "rule...", "projectId": "cmp...", "recipeId": null, "kind": "boost", "match": { "skus": ["SKU-1", "SKU-2"] }, "weight": 2, "position": null, "enabled": true, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}Create a merchandising rule
POST /api/v1/projects/{projectId}/recommendations/merchandising-rules: kind and match are required; weight or position are required depending on kind, per the table above. Returns the new rule, 201 Created. Requires recommendations:write.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules \ -X POST -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "kind": "boost", "match": { "category": "shoes" }, "weight": 2 }'const res = await fetch('https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'boost', match: { category: 'shoes' }, weight: 2 }) })const { data } = await res.json()import os, requestsr = requests.post("https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"kind": "boost", "match": {"category": "shoes"}, "weight": 2})data = r.json()["data"]{ "data": { "id": "rule...", "kind": "boost", "weight": 2, "...": "..." } }Pin an exact product to a slot instead, which uses position rather than weight:
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules \ -X POST -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d '{ "recipeId": "rec...", "kind": "pin", "match": { "skus": ["SKU-1"] }, "position": 0 }'Mixing a kind with the wrong field refuses before it reaches the database, for example pin sent with weight:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "issues": [{ "param": "weight", "path": ["weight"], "code": "custom", "message": "pin does not accept weight" }] }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" } }Update / delete a rule
PATCH and DELETE on /api/v1/projects/{projectId}/recommendations/merchandising-rules/{ruleId}. Same request shape as Create a merchandising rule above; only the URL and method change per language. Requires recommendations:write.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules/<ruleId> \ -X PATCH -H "Authorization: Bearer avsb_svc_..." -H "Content-Type: application/json" \ -d '{ "weight": 3 }'curl https://app.avsb.cloud/api/v1/projects/<projectId>/recommendations/merchandising-rules/<ruleId> \ -X DELETE -H "Authorization: Bearer avsb_svc_..."DELETE returns the deleted rule's id:
{ "data": { "id": "rule..." } }