Products
One endpoint: upsell suggestions, ranked by real bought-together history from your own orders and padded with same-category best sellers when that history is thin.
This is the same engine the offer-creation modal uses, so the API and the app never show different lists.
Recommendations
GET /data/products/recommendations| Parameter | Type | Notes |
|---|---|---|
offerId | string | Preferred. The app reads that offer's own trigger products, so you name nothing |
productId | string | A Shopify product gid — gid://shopify/Product/... |
productName | string | A product title, resolved on the server |
Pass none of them and you get the store's best sellers.
Four possible shapes
The reply states its own cause in fields rather than by absence. Branch on which key is present.
Bought-together suggestions
The normal answer.
| Field | Notes |
|---|---|
triggers | One entry per trigger product: id, title, plus the two lists below |
triggers[].boughtTogether | Real co-purchase evidence. Each row carries id, title, price, coPurchasedOrders, pastRevenue |
triggers[].sameCategoryPicks | Padding: same product type, ranked by store-wide orders. Carries id, title, price — and deliberately no co-purchase numbers |
At most 6 suggestions come back per trigger, counted across both lists together — co-purchase rows first, so padding is what gets dropped when the cap bites.
| omittedTriggers | Present when the offer had more trigger products than were looked up |
| deletedTriggers | Present when some of the offer's trigger products no longer exist in Shopify |
| currency | |
boughtTogether is a real answer: this store has no co-purchase history for that product yet. It never quietly becomes a best-seller list.Best sellers only
| Field | Notes |
|---|---|
shopBestSellers | id, title, price. At most 5 |
bestSellersOnly | true |
This is a real answer, not an error — and the cause is always stated as a field, so you never have to guess why. Exactly one of these rides along:
| Cause field | What happened |
|---|---|
nothingNamed: true | You sent no offerId, productId or productName |
hasProductTrigger: false | The offer's triggers name collections, tags or an order value — nothing bought-together history can answer |
hasProductTrigger: true with deletedTriggers | Every one of the offer's trigger products has been deleted from Shopify, so the offer cannot fire at all |
namedProduct: "deleted" | The productId you sent resolves to nothing |
namedProduct: "no_match" | No product goes by the productName you sent |
namedProduct separates two facts that send you looking in different places: an id that no longer resolves is a deleted product, while a title nothing matches is usually a typo or a renamed product.Ambiguous product name
| Field | Notes |
|---|---|
candidates | Products matching the title you sent |
Pick one and call again with its productId.
No product slot
| Field | Notes |
|---|---|
holdsUpsellProducts | false |
couponOffer | true |
A Coupon offer has no product slot to fill, so this shape carries no product list at all — not even best sellers, because a list on screen is itself the claim that it can be used here.
Example
curl -H "Authorization: Bearer $AOV_TOKEN" \
"https://aov-post-purchase.firebaseapp.com/public-api/v1/data/products/recommendations?offerId=8Kd2mQ"{
"success": true,
"data": {
"triggers": [
{
"id": "gid://shopify/Product/771",
"title": "Trail Runner GTX",
"boughtTogether": [
{ "id": "gid://shopify/Product/902", "title": "Merino Sock 3-pack", "price": 24, "coPurchasedOrders": 118, "pastRevenue": 2832 }
],
"sameCategoryPicks": [
{ "id": "gid://shopify/Product/915", "title": "Gaiters", "price": 32 }
]
}
],
"currency": "USD",
"shopDomain": "acme.myshopify.com"
}
}