{
  "info": {
    "_postman_id": "716fa71e-d52d-41c6-a3e0-ee8833eaf854",
    "name": "GalaxiQ \u2014 Product Recommendations",
    "description": "Everything a frontend needs, in the order it happens.\n\nSTEP 1  Add products \u2014 upload a CSV, or connect the merchant's product API.\nSTEP 2  Press the button \u2014 one call starts everything.\nSTEP 3  Show a progress bar \u2014 poll one endpoint until it says done.\nSTEP 4  Show the catalogue and its recommendations.\nSTEP 5  Let the merchant approve the uncertain ones.\nSTEP 6  Serve recommendations to shoppers.\n\nRequests save ids into collection variables, so the steps run in order without editing anything. Set base_url and tenant_id and go.\n\nEvery example response was captured from a real running service.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [
    {
      "key": "base_url",
      "value": "http://localhost:8001"
    },
    {
      "key": "tenant_id",
      "value": "org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f"
    },
    {
      "key": "job_id",
      "value": ""
    },
    {
      "key": "product_key",
      "value": "http_api:dummyjson.com:1"
    },
    {
      "key": "anchor_key",
      "value": "shopify:galaxiq-braexaal.myshopify.com:gid://shopify/Product/9298523095266"
    },
    {
      "key": "pair_anchor",
      "value": ""
    },
    {
      "key": "pair_neighbor",
      "value": ""
    },
    {
      "key": "pair_type",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "STEP 1 \u2014 Add the merchant's products",
      "description": "Three ways in: upload a spreadsheet, connect the merchant's product API, or point us at their website. Use any of them, or all three.\n\nNothing here fetches or runs the AI -- that is step 2. Connecting a source only saves the connection.",
      "item": [
        {
          "name": "Get the CSV template",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/sample.csv",
            "description": "Download this, fill it in, upload it in the next request. Column headers are plain English (Title, Description, Price, Currency, Image URL). A file exported straight out of Shopify also works unchanged."
          }
        },
        {
          "name": "Upload a CSV of products",
          "request": {
            "method": "POST",
            "header": [],
            "url": "{{base_url}}/catalog/import/csv",
            "description": "Pick the file in the Body tab.\n\nReturns straight away -- this is fast, there is no job to poll.\n\nRe-uploading a file with the SAME NAME replaces that batch. A different name adds more products.\n\nBad rows are reported with the spreadsheet row number; the good rows still import.\n\nCurrency comes from the file's Currency column, one per row, so a single upload can hold two currencies. It is optional and never guessed -- without it prices cannot be compared across sources, and nothing else changes.\n\nOptional form fields: currency (e.g. USD) for rows that leave the column blank, dry_run=true to check a file without saving it.",
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "tenant_id",
                  "value": "{{tenant_id}}",
                  "type": "text"
                },
                {
                  "key": "file",
                  "src": "products.csv",
                  "type": "file"
                }
              ]
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": "{{base_url}}/catalog/import/csv",
                "description": "Pick the file in the Body tab.\n\nReturns straight away -- this is fast, there is no job to poll.\n\nRe-uploading a file with the SAME NAME replaces that batch. A different name adds more products.\n\nBad rows are reported with the spreadsheet row number; the good rows still import.\n\nOptional: currency (e.g. USD), dry_run=true to check a file without saving it.",
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "tenant_id",
                      "value": "{{tenant_id}}",
                      "type": "text"
                    },
                    {
                      "key": "file",
                      "src": "products.csv",
                      "type": "file"
                    }
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"dry_run\": false,\n  \"products\": 14,\n  \"imported\": 14,\n  \"replaced\": 0,\n  \"errors\": []\n}"
            }
          ]
        },
        {
          "name": "Connect a product API \u2014 the minimum",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/http",
            "description": "For a merchant whose products live behind their own JSON API.\n\nONLY tenant_id and base_url are required. Which JSON field is the title, the price, the image and so on is worked out automatically the first time products are pulled in -- and so is the per-product URL, derived from the base URL and the id.\n\nThis only SAVES the connection. Nothing is fetched here; products arrive in step 2.\n\nbase_url must be https, and must not resolve to a private or internal address -- either returns 400.\n\nRe-posting the same host UPDATES that connection rather than adding a second one: the source is keyed on the hostname.\n\n\"needs\" lists anything still missing. It is informational -- the source works without it. Here it asks for a currency, because currency is never guessed from another connected source: a wrong guess silently mis-prices a whole catalogue.\n\nThe derived product URL points at the API, since that is all the base URL knows. Override it when a shopper must land somewhere else:\n  \"url_template\": \"https://shop.example.com/products/{external_id}\"\nor map the API's own URL field:\n  \"fields\": {\"product_url\": \"$.permalink\"}\n\nWith neither, products import but are held back from shoppers -- a recommendation nobody can click is worse than one not shown.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://dummyjson.com/products\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/http",
                "description": "For a merchant whose products live behind their own JSON API.\n\nONLY tenant_id and base_url are required. Which JSON field is the title, the price, the image and so on is worked out automatically the first time products are pulled in -- and so is the per-product URL, derived from the base URL and the id.\n\nThis only SAVES the connection. Nothing is fetched here; products arrive in step 2.\n\nbase_url must be https, and must not resolve to a private or internal address -- either returns 400.\n\nRe-posting the same host UPDATES that connection rather than adding a second one: the source is keyed on the hostname.\n\n\"needs\" lists anything still missing. It is informational -- the source works without it. Here it asks for a currency, because currency is never guessed from another connected source: a wrong guess silently mis-prices a whole catalogue.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://dummyjson.com/products\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"http_api\",\n  \"external_ref\": \"dummyjson.com\",\n  \"status\": \"active\",\n  \"mapping\": \"pending_first_sync\",\n  \"needs\": [\n    \"currency\"\n  ]\n}"
            }
          ]
        },
        {
          "name": "With a currency",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/http",
            "description": "The same call with the one thing \"needs\" asked for. \"needs\" comes back empty.\n\nCurrency is what lets prices be compared across sources. Without it products still import and still get recommended -- they are only flagged as unconvertible.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://dummyjson.com/products\",\n  \"currency\": \"USD\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/http",
                "description": "The same call with the one thing \"needs\" asked for. \"needs\" comes back empty.\n\nCurrency is what lets prices be compared across sources. Without it products still import and still get recommended -- they are only flagged as unconvertible.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://dummyjson.com/products\",\n  \"currency\": \"USD\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"http_api\",\n  \"external_ref\": \"dummyjson.com\",\n  \"status\": \"active\",\n  \"mapping\": \"pending_first_sync\",\n  \"needs\": []\n}"
            }
          ]
        },
        {
          "name": "With an API key (bearer)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/http",
            "description": "Sends Authorization: Bearer <api_key> on every fetch.\n\nThe key is encrypted at rest and is never returned by any endpoint -- GET /sources shows the config but never the credentials.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/products\",\n  \"currency\": \"USD\",\n  \"auth\": {\n    \"style\": \"bearer\"\n  },\n  \"api_key\": \"sk_live_51H8xKfE2mQ...\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/http",
                "description": "Sends Authorization: Bearer <api_key> on every fetch.\n\nThe key is encrypted at rest and is never returned by any endpoint -- GET /sources shows the config but never the credentials.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/products\",\n  \"currency\": \"USD\",\n  \"auth\": {\n    \"style\": \"bearer\"\n  },\n  \"api_key\": \"sk_live_51H8xKfE2mQ...\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"http_api\",\n  \"external_ref\": \"api.example.com\",\n  \"status\": \"active\",\n  \"mapping\": \"pending_first_sync\",\n  \"needs\": []\n}"
            }
          ]
        },
        {
          "name": "With an API key (custom header)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/http",
            "description": "For an API that wants its key in a header of its own choosing.\n\nauth.style is one of: none (the default), bearer, header. \"header\" additionally needs \"header\" -- the name to send the key under.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/products\",\n  \"currency\": \"USD\",\n  \"auth\": {\n    \"style\": \"header\",\n    \"header\": \"X-API-Key\"\n  },\n  \"api_key\": \"sk_live_51H8xKfE2mQ...\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/http",
                "description": "For an API that wants its key in a header of its own choosing.\n\nauth.style is one of: none (the default), bearer, header. \"header\" additionally needs \"header\" -- the name to send the key under.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/products\",\n  \"currency\": \"USD\",\n  \"auth\": {\n    \"style\": \"header\",\n    \"header\": \"X-API-Key\"\n  },\n  \"api_key\": \"sk_live_51H8xKfE2mQ...\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"http_api\",\n  \"external_ref\": \"api.example.com\",\n  \"status\": \"active\",\n  \"mapping\": \"pending_first_sync\",\n  \"needs\": []\n}"
            }
          ]
        },
        {
          "name": "When the products are nested, or paginate differently",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/http",
            "description": "Defaults assume the products are at $.products and that the API paginates with limit/skip in pages of 30, reporting the total at $.total. Override either when they do not.\n\nrecords_path is JSONPath to the ARRAY of products in the response.\n\npagination.style: \"offset\" is the only style implemented. Anything else is accepted here and then fails at the first sync, so do not send one.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/catalog\",\n  \"currency\": \"USD\",\n  \"records_path\": \"$.data.items\",\n  \"pagination\": {\n    \"style\": \"offset\",\n    \"limit_param\": \"per_page\",\n    \"offset_param\": \"offset\",\n    \"page_size\": 100,\n    \"total_path\": \"$.meta.total_count\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/http",
                "description": "Defaults assume the products are at $.products and that the API paginates with limit/skip in pages of 30, reporting the total at $.total. Override either when they do not.\n\nrecords_path is JSONPath to the ARRAY of products in the response.\n\npagination.style: \"offset\" is the only style implemented. Anything else is accepted here and then fails at the first sync, so do not send one.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"base_url\": \"https://api.example.com/v1/catalog\",\n  \"currency\": \"USD\",\n  \"records_path\": \"$.data.items\",\n  \"pagination\": {\n    \"style\": \"offset\",\n    \"limit_param\": \"per_page\",\n    \"offset_param\": \"offset\",\n    \"page_size\": 100,\n    \"total_path\": \"$.meta.total_count\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"http_api\",\n  \"external_ref\": \"api.example.com\",\n  \"status\": \"active\",\n  \"mapping\": \"pending_first_sync\",\n  \"needs\": []\n}"
            }
          ]
        },
        {
          "name": "Connect the merchant's website",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/sources/website",
            "description": "The third way in, for a merchant with neither a spreadsheet nor an API: they give us their website and we read the products off it.\n\nONLY tenant_id and url are required.\n\nPrices, brand and category come from the page's own structured markup where it has any -- schema.org's Product type, or Shopify's ProductGroup type (used by many current themes for products with variants: brand/category/name sit at the top level, price comes from the first variant's own offer). That is exact, so nothing is guessed there. A page with neither falls back to a dedicated LLM read of the page text for brand/price -- validated (a non-numeric or non-positive price is rejected, not coerced) and only ever used when structured markup had nothing. Whatever is still missing after both is flagged in missing_fields on the product, never silently guessed.\n\nCurrency is NOT required here, unlike a connected product API. If no page states one in its own markup, it is inferred at crawl time from page context (domain, locale, shipping/tax text) by a dedicated LLM call that only ever answers with a currency or null -- it never touches price. \"needs\" therefore never lists currency for this source kind.\n\nSaving the connection also STARTS A BUILD right away for the whole tenant (sync -> enrich -> pair) -- the response's job_id/job_status tell you what happened (queued | already_running_without_this_source | not_started). Set \"auto_build\": false to only save the connection and trigger nothing, matching the old two-step flow -- useful when connecting several sources in a row and you want one build at the end instead of one per source.\n\nIMPORTANT -- this source is NOT re-crawled on every build after its first. Reading a whole website is dozens of requests to the merchant's own server, unlike one API call, so it is re-read at most once every 24 hours. force=true on the build forces it, and \"min_interval_hours\": 0 here opts out entirely.\n\nOptional:\n  currency            never required -- see above. Set it here only to\n                      skip inference and pin an exact value.\n  max_pages           how far the crawl may go (defaults to the server's\n                      configured limit, currently 60)\n  min_interval_hours  0 to re-crawl on every build\n  auto_build          false to skip the automatic build\n\nurl must be https and publicly resolvable, or 400.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"url\": \"https://shop.example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/sources/website",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"url\": \"https://shop.example.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"kind\": \"crawl\",\n  \"external_ref\": \"shop.example.com\",\n  \"status\": \"active\",\n  \"products\": \"pending_first_crawl\",\n  \"needs\": [],\n  \"job_id\": \"job_bd08789f384049aa97680293dd0a4830\",\n  \"job_status\": \"queued\"\n}"
            }
          ]
        },
        {
          "name": "List connected sources",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/sources?tenant_id={{tenant_id}}",
            "description": "What this merchant has connected, when it last updated, and anything still missing.\n\nNever includes credentials.\n\n`config` is where you see what was inferred on the first sync -- the field map and the derived url_template -- so this is how you check whether an override is needed."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/sources?tenant_id={{tenant_id}}",
                "description": "What this merchant has connected, when it last updated, and anything still missing."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"sources\": [\n    {\n      \"kind\": \"shopify\",\n      \"external_ref\": \"galaxiq-braexaal.myshopify.com\",\n      \"config\": {\n        \"scopes\": \"read_products,read_inventory\",\n        \"shop_name\": \"Galaxiq\",\n        \"api_version\": \"2026-07\",\n        \"shop_domain\": \"galaxiq-braexaal.myshopify.com\",\n        \"currency_code\": \"INR\",\n        \"primary_domain\": \"https://galaxiq-braexaal.myshopify.com\"\n      },\n      \"status\": \"active\",\n      \"connected_at\": \"2026-08-11 07:49:43.974847+00:00\",\n      \"last_synced_at\": \"2026-08-12 05:02:14.108642+00:00\",\n      \"needs\": []\n    }\n  ],\n  \"platform_integration\": {\n    \"shopify\": \"ACTIVE\"\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "STEP 2 \u2014 Press the button",
      "description": "One call. One job. This is the only endpoint the button needs.\n\nBelow is what actually happens behind it, for anyone who has to explain a\nprogress bar that sits at 40% for a minute.\n\n\nHOW PRODUCTS ARE FETCHED\n------------------------\nFor each connected API, in turn:\n\n1. The stored base_url is re-checked before every fetch -- https only, and\n   never an internal address. A URL saved months ago is as untrusted as one\n   typed today, and it is re-fetched on every run.\n\n2. Pages are pulled with limit/skip until the API's total is reached, or a\n   page comes back empty. Page size is 30 by default.\n\n3. EVERY PAGE OR NOTHING. If any page fails, the whole fetch fails and\n   nothing is written. A partial fetch that looked complete would make the\n   next step delete every product it simply never saw.\n\n4. On the FIRST run only, the first record is shown to the model, which\n   works out which JSON key is the title, the price, the image. That map is\n   saved on the source, so every run after this one skips inference and its\n   model call. The per-product URL is derived at the same time.\n\n5. Products are written by product_key, so a second run updates rows rather\n   than duplicating them. Anything not seen in this run is then deleted --\n   but ONLY within this source. Deletes are scoped to (source_kind,\n   source_ref), so a Shopify sync can never remove the CSV's products, and\n   two connected APIs cannot remove each other's.\n\nA source that fails is marked and the run carries on with the others. One\nbroken API does not cost the merchant their whole catalogue.\n\n\nWHAT A REFRESH ACTUALLY REDOES\n------------------------------\nPressing the button again runs all three stages, but almost everything is\ncached on a content hash covering only the fields that affect matching --\nname, brand, category, tags, attributes.\n\n  Fetch      always runs. Prices and stock are re-read every time.\n  Enrich     only products whose content hash CHANGED are re-read by the\n             model. The rest are reported as skipped_unchanged.\n  Pair       re-embeds only what enrichment touched, then rebuilds the graph.\n\nSo a price change costs one fetch and nothing else. Renaming a product\nre-enriches that product alone. This is why the first run takes minutes and\nthe next takes seconds.\n\nA merchant's approvals and rejections are NEVER touched by a rebuild. They\nare recorded against the pair, and a re-run re-applies them.\n\nforce=true throws the cache away and re-reads every product. It is for when\nthe extraction logic changed, not for when the catalogue did -- on a real\ncatalogue it costs the full first-run time again.\n\n\nHOW RECOMMENDATIONS ARE BUILT\n-----------------------------\nThe three stages share one progress bar:\n\n  sync     0-10%    fetch every connected source\n  enrich   10-60%   the model reads each product and extracts attributes --\n                    colour, material, gender, and whether the thing is an\n                    accessory FOR something\n  pair     60-100%  embed each product, find its nearest neighbours, and\n                    score them into three kinds of pair\n\nThe order is not a preference. Pairing reads what enrichment writes:\nis_accessory is what makes a phone suggest a case and stops a case\nsuggesting a phone. Pairing an un-enriched catalogue produces a graph with\nno complements worth having.\n\nEach pair gets two numbers, and they do different jobs:\n\n  score       how good the match is. This is what you SORT and FILTER by,\n              and what the 80% / 60-79% / under-60% bands in STEP 5 read.\n  confidence  how sure the system is that it is right.\n\nCONFIDENCE, not score, decides whether a merchant has to look at it. A pair\nserves without review when confidence is 0.70 or above, or when the\nmerchant's own data declared it -- a merchant's declaration outranks any\ninference, so it never queues. Everything else waits in STEP 5.\n\nA merchant's decision always wins over both numbers, in either direction.\n\nOne consequence worth knowing before you build the screen: filtering for\nstatus=pending AND score=high comes back empty on a real catalogue. Pending\nmeans low confidence, and for similar-type pairs confidence caps the score,\nso a pending pair cannot reach 0.80. That is arithmetic, not a bug.\n\nOnly ONE build runs per merchant at a time. That is enforced by a database\nindex rather than an application check, so two servers cannot both start\none. A second press returns 409 with the job id already running -- poll\nthat one instead of showing an error.\n\nA tenant with only a CSV has nothing to fetch: sync is skipped, the bar\njumps to 10%, and the rest runs normally.",
      "item": [
        {
          "name": "Generate recommendations",
          "request": {
            "method": "POST",
            "header": [],
            "url": "{{base_url}}/catalog/build",
            "description": "THE BUTTON. Fetches the latest products from any connected API, has the AI read every product, then works out which products go together.\n\nReturns immediately with a job_id. Take that to STEP 3 and poll it for the progress bar.\n\nTakes a few minutes the first time on a real catalogue, and seconds after that -- the work is cached, so pressing it again after a small change is quick. The folder description explains exactly what a re-run redoes and what it skips.\n\nPress it twice and the second call returns 409 with the job_id that is already running -- poll that one rather than showing an error.\n\nThis is also the REFRESH button. There is no separate refresh endpoint: the same call re-fetches, re-enriches what changed, and rebuilds the graph. Merchant approvals survive it.\n\nOptional form fields:\n  force=true   re-read every product from scratch, ignoring the cache.\n               Slow. Only for when the extraction itself changed.\n  wait=true    run it synchronously and return the report instead of a\n               job_id. For scripts and debugging -- never for the UI,\n               it holds the connection open for the whole build.",
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "tenant_id",
                  "value": "{{tenant_id}}",
                  "type": "text"
                },
                {
                  "key": "force",
                  "value": "false",
                  "type": "text",
                  "disabled": true,
                  "description": "re-read every product, ignoring the cache"
                }
              ]
            }
          },
          "response": [
            {
              "name": "202 Accepted",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": "{{base_url}}/catalog/build",
                "description": "THE BUTTON. Fetches the latest products from any connected API, has the AI read every product, then works out which products go together.\n\nReturns immediately with a job_id. Take that to STEP 3 and poll it for the progress bar.\n\nTakes a few minutes the first time on a real catalogue, and seconds after that -- the work is cached, so pressing it again after a small change is quick.\n\nPress it twice and the second call returns 409 with the job_id that is already running -- poll that one rather than showing an error.\n\nOptional: force=true makes the AI re-read every product from scratch, ignoring the cache. Slow. Only needed if the extraction itself changed.",
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "tenant_id",
                      "value": "{{tenant_id}}",
                      "type": "text"
                    }
                  ]
                }
              },
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"job_id\": \"job_bd08789f384049aa97680293dd0a4830\",\n  \"status\": \"queued\"\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "if (body.job_id) pm.collectionVariables.set('job_id', body.job_id);",
                  "pm.test('started', () => pm.response.to.have.status(202));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "STEP 3 \u2014 Show a progress bar",
      "item": [
        {
          "name": "Check progress",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/jobs/{{job_id}}",
            "description": "Send this every 2-3 seconds while the button is spinning.\n\npercent  0-100. Only ever goes up. Use it directly for the bar.\nstep     a sentence to show under the bar, e.g. \"scoring candidate pairs \u2014 3668 of 8687\".\nstatus   queued | running | done | failed | lost\n\nStop polling when status is done, failed or lost.\n\n  done    -> result holds the summary. Refresh the catalogue.\n  failed  -> show an error and offer a retry. error holds a short code.\n  lost    -> the server restarted mid-job. Treat it like failed and offer a retry.\n\n404 means the job id is unknown or older than 24 hours."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/jobs/{{job_id}}",
                "description": "Send this every 2-3 seconds while the button is spinning.\n\npercent  0-100. Only ever goes up. Use it directly for the bar.\nstep     a sentence to show under the bar, e.g. \"scoring candidate pairs \u2014 3668 of 8687\".\nstatus   queued | running | done | failed | lost\n\nStop polling when status is done, failed or lost.\n\n  done    -> result holds the summary. Refresh the catalogue.\n  failed  -> show an error and offer a retry. error holds a short code.\n  lost    -> the server restarted mid-job. Treat it like failed and offer a retry.\n\n404 means the job id is unknown or older than 24 hours."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"job_id\": \"job_bd08789f384049aa97680293dd0a4830\",\n  \"kind\": \"build\",\n  \"tenant_id\": \"org_8c32bf3e-\\u2026\",\n  \"status\": \"running\",\n  \"percent\": 85,\n  \"step\": \"scoring candidate pairs \\u2014 3668 of 8687\",\n  \"started_at\": \"2026-08-11T15:04:02Z\",\n  \"updated_at\": \"2026-08-11T15:06:31Z\",\n  \"result\": null,\n  \"error\": null\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const s = pm.response.json();",
                  "console.log(s.percent + '% - ' + (s.step || s.status));",
                  "pm.test('valid status', () => pm.expect(",
                  "  ['queued','running','done','failed','lost']).to.include(s.status));",
                  "// Keep sending this until status is done, failed or lost."
                ]
              }
            }
          ]
        },
        {
          "name": "Recent runs",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/jobs?tenant_id={{tenant_id}}&limit=20",
            "description": "The last few runs for this merchant, newest first. Useful for a \"last updated\" line."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/jobs?tenant_id={{tenant_id}}&limit=20",
                "description": "The last few runs for this merchant, newest first. Useful for a \"last updated\" line."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "[\n  {\n    \"job_id\": \"job_efd17575540b4b0ead65e9e8a3bb83c8\",\n    \"tenant_id\": \"org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f\",\n    \"kind\": \"pair\",\n    \"status\": \"done\",\n    \"percent\": 100,\n    \"step\": \"writing pairing graph\",\n    \"result\": {\n      \"products\": 212,\n      \"pairs\": 1247,\n      \"servable\": 1246,\n      \"queued\": 1,\n      \"by_type\": {\n        \"similar\": 354,\n        \"complement\": 258,\n        \"upsell\": 635\n      }\n    },\n    \"error\": null,\n    \"created_at\": 1786456619.278429,\n    \"updated_at\": 1786456628.637562\n  },\n  {\n    \"job_id\": \"job_232a9acccb6246f2ba7d771925b50868\",\n    \"tenant_id\": \"org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f\",\n    \"kind\": \"enrich\",\n    \"status\": \"done\",\n    \"percent\": 100,\n    \"step\": \"extracting attributes \\u2014 batch 11 of 11\",\n    \"result\": {\n      \"products\": 218,\n      \"considered\": 218,\n      \"extracted\": 218,\n      \"skipped_unchanged\": 0,\n      \"skipped_unextractable\": 0,\n      \"failed\": 0,\n      \"conflicts\": 0,\n      \"attribute_coverage\": 1.0\n    },\n    \"error\": null,\n    \"created_at\": 1786456619.266828,\n    \"updated_at\": 1786456803.173127\n  },\n  {\n    \"job_id\": \"job_e1c92b0ff1c341b09788ef1133ff9d65\",\n    \"tenant_id\": \"org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f\",\n    \"kind\": \"enrich\",\n    \"status\": \"failed\",\n    \"percent\": 100,\n    \"step\": \"\",\n    \"result\": null,\n    \"error\": \"Abandoned\",\n    \"created_at\": 1786456287.271592,\n    \"updated_at\": 1786456528.317661\n  }\n]"
            }
          ]
        }
      ]
    },
    {
      "name": "STEP 4 \u2014 Show the catalogue",
      "item": [
        {
          "name": "Category list",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/categories?tenant_id={{tenant_id}}",
            "description": "Every category with how many products are in it."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/categories?tenant_id={{tenant_id}}",
                "description": "Every category with how many products are in it."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "[\n  {\n    \"category\": \"Gift Cards\",\n    \"products\": 1\n  },\n  {\n    \"category\": \"accessories\",\n    \"products\": 1\n  },\n  {\n    \"category\": \"beauty\",\n    \"products\": 5\n  },\n  {\n    \"category\": \"fragrances\",\n    \"products\": 5\n  }\n]"
            }
          ]
        },
        {
          "name": "Product list",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/products?tenant_id={{tenant_id}}&limit=20&offset=0",
            "description": "Paged. Add &category=smartphones to filter.\n\n`total` is the full count -- use it for the pager. Move through pages with offset."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/products?tenant_id={{tenant_id}}&limit=20&offset=0",
                "description": "Paged. Add &category=smartphones to filter.\n\n`total` is the full count -- use it for the pager. Move through pages with offset."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"products\": [\n    {\n      \"product_key\": \"http_api:dummyjson.com:1\",\n      \"name\": \"Essence Mascara Lash Princess\",\n      \"category\": \"beauty\",\n      \"brand\": \"Essence\",\n      \"price_cents\": 999,\n      \"currency\": \"INR\",\n      \"in_stock\": true,\n      \"image_url\": \"https://cdn.dummyjson.com/product-images/beauty/essence-mascara-lash-princess/thumbnail.webp\"\n    },\n    {\n      \"product_key\": \"http_api:dummyjson.com:10\",\n      \"name\": \"Gucci Bloom Eau de\",\n      \"category\": \"fragrances\",\n      \"brand\": \"Gucci\",\n      \"price_cents\": 7999,\n      \"currency\": \"INR\",\n      \"in_stock\": true,\n      \"image_url\": \"https://cdn.dummyjson.com/product-images/fragrances/gucci-bloom-eau-de/thumbnail.webp\"\n    }\n  ],\n  \"total\": 218\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const first = (pm.response.json().products || [])[0];",
                  "if (first) pm.collectionVariables.set('product_key', first.product_key);"
                ]
              }
            }
          ]
        },
        {
          "name": "One product",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/products/{{product_key}}?tenant_id={{tenant_id}}",
            "description": "One product, in full. This is the row behind the catalogue screen.\n\nproduct_key encodes the source (http_api:dummyjson.com:1), so URL-encode it before putting it in the path.\n\nmissing_fields is the one to read: a product missing product_url is held back from shoppers entirely, because a recommendation nobody can click is worse than one not shown. Show it here so the merchant can see WHY something is not being recommended instead of concluding the system is broken.\n\n404 if the key is unknown for this tenant."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/products/{{product_key}}?tenant_id={{tenant_id}}",
                "description": "Everything stored about one product.\n\nNOTE: product keys contain colons, e.g. http_api:dummyjson.com:121. URL-encode them (encodeURIComponent) or the request will 404."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"product_key\": \"http_api:dummyjson.com:1\",\n  \"name\": \"Essence Mascara Lash Princess\",\n  \"description\": \"The Essence Mascara Lash Princess is a popular mascara known for its volumizing and lengthening effects. Achieve dramatic lashes with this long-lasting and cruelty-free formula.\",\n  \"image_url\": \"https://cdn.dummyjson.com/product-images/beauty/essence-mascara-lash-princess/thumbnail.webp\",\n  \"product_url\": \"https://dummyjson.com/auth/products/1\",\n  \"category\": \"beauty\",\n  \"ctas\": [],\n  \"options\": [],\n  \"related_keys\": [],\n  \"extracted_at\": \"2026-08-10 15:57:58.866753+00:00\",\n  \"source_kind\": \"http_api\",\n  \"source_ref\": \"dummyjson.com\",\n  \"external_id\": \"1\",\n  \"brand\": \"Essence\",\n  \"taxonomy_path\": [\n    \"beauty\"\n  ],\n  \"taxonomy_source\": \"native\",\n  \"raw_category\": \"beauty\",\n  \"price_cents\": 999,\n  \"price_max_cents\": 999,\n  \"compare_at_cents\": 1116,\n  \"currency\": \"INR\",\n  \"on_sale\": true,\n  \"in_stock\": true,\n  \"status\": \"ACTIVE\",\n  \"attributes\": [\n    {\n      \"key\": \"color\",\n      \"unit\": null,\n      \"value\": \"black\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"black\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"feature\",\n      \"unit\": null,\n      \"value\": \"cruelty free\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"cruelty free\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"feature\",\n      \"unit\": null,\n      \"value\": \"dramatic lashes\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"dramatic lashes\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"feature\",\n      \"unit\": null,\n      \"value\": \"lengthening effect\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"lengthening effect\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"feature\",\n      \"unit\": null,\n      \"value\": \"long lasting\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"long lasting\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"gender\",\n      \"unit\": null,\n      \"value\": \"female\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"female\",\n      \"confidence\": 0.5\n    },\n    {\n      \"key\": \"style\",\n      \"unit\": null,\n      \"value\": \"volumizing\",\n      \"source\": \"inferred\",\n      \"raw_value\": \"volumizing\",\n      \"confidence\": 0.5\n    }\n  ],\n  \"quality_score\": 0.55,\n  \"synced_at\": \"2026-08-10 15:57:50.760963+00:00\",\n  \"tenant_relations\": {},\n  \"rating\": 2.56,\n  \"review_count\": null,\n  \"featured_rank\": null,\n  \"missing_fields\": [],\n  \"price_reference_cents\": 999,\n  \"fx_rate_used\": 1.0,\n  \"is_accessory\": false,\n  \"price_tier\": \"budget\"\n}"
            }
          ]
        },
        {
          "name": "What this product goes with",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/products/{{product_key}}/pairings?tenant_id={{tenant_id}}",
            "description": "Grouped into three lists:\n\n  similar     other options INSTEAD of this one\n  complement  things to buy ALONG WITH this one\n  upsell      a BETTER, pricier version\n\nEach entry has the other product's name, image and price, a score (multiply by 100 for a match percentage), the reasons it was chosen -- show these, a bare number means nothing to a merchant -- and `servable`, which is false while it is still waiting for approval."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/products/{{product_key}}/pairings?tenant_id={{tenant_id}}",
                "description": "Grouped into three lists:\n\n  similar     other options INSTEAD of this one\n  complement  things to buy ALONG WITH this one\n  upsell      a BETTER, pricier version\n\nEach entry has the other product's name, image and price, a score (multiply by 100 for a match percentage), the reasons it was chosen -- show these, a bare number means nothing to a merchant -- and `servable`, which is false while it is still waiting for approval."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"similar\": [],\n  \"complement\": [],\n  \"upsell\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:1\",\n      \"neighbor_key\": \"http_api:dummyjson.com:2\",\n      \"pair_type\": \"upsell\",\n      \"score\": 0.7503,\n      \"confidence\": 0.95,\n      \"source\": \"arithmetic\",\n      \"reasons\": [\n        \"2.0x the price\"\n      ],\n      \"computed_at\": \"2026-08-11T14:10:18.870201+00:00\",\n      \"neighbor\": {\n        \"name\": \"Eyeshadow Palette with Mirror\",\n        \"category\": \"beauty\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/beauty/eyeshadow-palette-with-mirror/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"servable\": true\n    }\n  ]\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "STEP 5 \u2014 Merchant approval",
      "description": "Uncertain matches are never shown to shoppers until a person approves them.\n\nTwo GET endpoints do all the reading -- one for products, one for pairs -- and they take the SAME filters, so the screen keeps one filter bar and points it at either. The requests below are the combinations a review screen actually sends.",
      "item": [
        {
          "name": "Products waiting for review",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=pending&limit=20",
            "description": "The review screen's top row: one entry per product, not per pair. A merchant thinks in products -- \"this shirt has 4 to review\" is workable, a flat list of 232 pairs from 108 products is not.\n\nFILTERS -- all optional, all combine with each other:\n\n  status=pending    waiting for a person          (the default)\n  status=approved   a person said yes\n  status=rejected   a person said no\n  status=auto       scored high enough to go live without review\n  status=all        every state\n\n  score=high        0.80 and above\n  score=medium      0.60 to 0.79\n  score=low         under 0.60\n\n  category=tops     one category, exactly as /catalog/categories spells it\n\nAny other value returns 422 rather than an empty list -- a typo must not look\nlike \"you have no work to do\".\n\nPER ANCHOR:\n  matching   how many pairs match the CURRENT filter -- your badge\n  pending / approved / rejected / auto\n             the FULL breakdown, ignoring the filter, so one call\n             gives you \"4 to review, 2 approved\" per product\n  top_score / avg_score   best and average of the matching pairs\n\n`total` is the count before `limit`, for your pager. `avg_score` at the top level is the average across the page.\n\nSorted best-candidate-first, so the merchant meets the most promising decisions while they still have patience for them."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=pending&limit=20",
                "description": "The review screen's top row: one entry per product, not per pair. A merchant thinks in products -- \"this shirt has 4 to review\" is workable, a flat list of 232 pairs from 108 products is not.\n\nFILTERS -- all optional, all combine with each other:\n\n  status=pending    waiting for a person          (the default)\n  status=approved   a person said yes\n  status=rejected   a person said no\n  status=auto       scored high enough to go live without review\n  status=all        every state\n\n  score=high        0.80 and above\n  score=medium      0.60 to 0.79\n  score=low         under 0.60\n\n  category=tops     one category, exactly as /catalog/categories spells it\n\nAny other value returns 422 rather than an empty list -- a typo must not look\nlike \"you have no work to do\".\n\nPER ANCHOR:\n  matching   how many pairs match the CURRENT filter -- your badge\n  pending / approved / rejected / auto\n             the FULL breakdown, ignoring the filter, so one call\n             gives you \"4 to review, 2 approved\" per product\n  top_score / avg_score   best and average of the matching pairs\n\n`total` is the count before `limit`, for your pager. `avg_score` at the top level is the average across the page.\n\nSorted best-candidate-first, so the merchant meets the most promising decisions while they still have patience for them."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"anchors\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:85\",\n      \"anchor\": {\n        \"name\": \"Man Plaid Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 3499,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-plaid-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 2,\n      \"pending\": 2,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 5,\n      \"top_score\": 0.6165,\n      \"avg_score\": 0.5817\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:86\",\n      \"anchor\": {\n        \"name\": \"Man Short Sleeve Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-short-sleeve-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 2,\n      \"pending\": 2,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 8,\n      \"top_score\": 0.6165,\n      \"avg_score\": 0.5581\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:51\",\n      \"anchor\": {\n        \"name\": \"Boxed Blender\",\n        \"category\": \"kitchen-accessories\",\n        \"price_cents\": 3999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/kitchen-accessories/boxed-blender/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 1,\n      \"pending\": 1,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 1,\n      \"top_score\": 0.5973,\n      \"avg_score\": 0.5973\n    }\n  ],\n  \"total\": 108,\n  \"avg_score\": 0.579\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const data = pm.response.json();",
                  "const rows = data.anchors || [];",
                  "if (rows.length) {",
                  "  pm.collectionVariables.set('anchor_key', rows[0].anchor_key);",
                  "  console.log(rows[0].matching + ' match(es) for ' + rows[0].anchor.name);",
                  "}",
                  "console.log(data.total + ' product(s) match this filter');"
                ]
              }
            }
          ]
        },
        {
          "name": "Waiting for review, in one category",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=pending&category=mens-shirts&limit=20",
            "description": "Two filters at once. Same call as above, narrowed to one category -- this is what the category dropdown on the review screen sends.\n\nOn the live catalogue this cuts 108 products down to 4.\n\nSpell the category exactly as /catalog/categories returns it."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=pending&category=mens-shirts&limit=20",
                "description": "Two filters at once. Same call as above, narrowed to one category -- this is what the category dropdown on the review screen sends.\n\nOn the live catalogue this cuts 108 products down to 4.\n\nSpell the category exactly as /catalog/categories returns it."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"anchors\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:85\",\n      \"anchor\": {\n        \"name\": \"Man Plaid Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 3499,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-plaid-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 2,\n      \"pending\": 2,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 5,\n      \"top_score\": 0.6165,\n      \"avg_score\": 0.5817\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:86\",\n      \"anchor\": {\n        \"name\": \"Man Short Sleeve Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-short-sleeve-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 2,\n      \"pending\": 2,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 8,\n      \"top_score\": 0.6165,\n      \"avg_score\": 0.5581\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:83\",\n      \"anchor\": {\n        \"name\": \"Blue & Black Check Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 2999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/blue-&-black-check-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 1,\n      \"pending\": 1,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 7,\n      \"top_score\": 0.5469,\n      \"avg_score\": 0.5469\n    }\n  ],\n  \"total\": 4,\n  \"avg_score\": 0.5622\n}"
            }
          ]
        },
        {
          "name": "Already live, no review needed",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=auto&score=high&limit=20",
            "description": "Three filters' worth of the other end of the queue: matches that scored high enough to serve without anyone approving them.\n\nUseful for a \"nothing to do here\" tab -- 97 of the catalogue's 205 products are already live at 0.99 average, which is why the review queue is short.\n\nNote `pending` and `approved` are still filled in on each row: the breakdown ignores the filter on purpose."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=auto&score=high&limit=20",
                "description": "Three filters' worth of the other end of the queue: matches that scored high enough to serve without anyone approving them.\n\nUseful for a \"nothing to do here\" tab -- 97 of the catalogue's 205 products are already live at 0.99 average, which is why the review queue is short.\n\nNote `pending` and `approved` are still filled in on each row: the breakdown ignores the filter on purpose."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"anchors\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:102\",\n      \"anchor\": {\n        \"name\": \"Apple Airpower Wireless Charger\",\n        \"category\": \"mobile-accessories\",\n        \"price_cents\": 7999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mobile-accessories/apple-airpower-wireless-charger/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 1,\n      \"pending\": 0,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 5,\n      \"top_score\": 1.0,\n      \"avg_score\": 1.0\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:104\",\n      \"anchor\": {\n        \"name\": \"Apple iPhone Charger\",\n        \"category\": \"mobile-accessories\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mobile-accessories/apple-iphone-charger/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 4,\n      \"pending\": 0,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 4,\n      \"top_score\": 1.0,\n      \"avg_score\": 0.9688\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:105\",\n      \"anchor\": {\n        \"name\": \"Apple MagSafe Battery Pack\",\n        \"category\": \"mobile-accessories\",\n        \"price_cents\": 9999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mobile-accessories/apple-magsafe-battery-pack/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"matching\": 1,\n      \"pending\": 0,\n      \"approved\": 0,\n      \"rejected\": 0,\n      \"auto\": 2,\n      \"top_score\": 1.0,\n      \"avg_score\": 1.0\n    }\n  ],\n  \"total\": 97,\n  \"avg_score\": 0.9896\n}"
            }
          ]
        },
        {
          "name": "What the merchant approved",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=approved&limit=20",
            "description": "The same call with status=approved. Nothing has been approved on this tenant yet, so this is also the empty response your screen must handle: `anchors` is an empty array, `total` is 0, and `avg_score` is 0.0 -- never null, never a 404.\n\nApprove something with the request further down and it appears here."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=approved&limit=20",
                "description": "The same call with status=approved. Nothing has been approved on this tenant yet, so this is also the empty response your screen must handle: `anchors` is an empty array, `total` is 0, and `avg_score` is 0.0 -- never null, never a 404.\n\nApprove something with the request further down and it appears here."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"anchors\": [],\n  \"total\": 0,\n  \"avg_score\": 0.0\n}"
            }
          ]
        },
        {
          "name": "That product's matches",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&anchor_key={{anchor_key}}&status=pending",
            "description": "The cards for one product's review screen: both products, the score, and the reasons in plain English.\n\nTakes exactly the same filters as the anchor list. Drop `anchor_key` to browse the whole catalogue instead of one product.\n\nEach pair carries `state` (pending | approved | rejected | auto) and `decision` -- what a person actually recorded, or null if nobody has.\n\n`dropped` counts pairs hidden because a product went out of stock or lost its URL since the run. A non-zero value there explains a short list; it is not an error."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&anchor_key={{anchor_key}}&status=pending",
                "description": "The cards for one product's review screen: both products, the score, and the reasons in plain English.\n\nTakes exactly the same filters as the anchor list. Drop `anchor_key` to browse the whole catalogue instead of one product.\n\nEach pair carries `state` (pending | approved | rejected | auto) and `decision` -- what a person actually recorded, or null if nobody has.\n\n`dropped` counts pairs hidden because a product went out of stock or lost its URL since the run. A non-zero value there explains a short list; it is not an error."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"pairings\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:85\",\n      \"neighbor_key\": \"http_api:dummyjson.com:86\",\n      \"pair_type\": \"similar\",\n      \"score\": 0.6165,\n      \"confidence\": 0.6664,\n      \"source\": \"embedding\",\n      \"reasons\": [\n        \"same category (mens-shirts)\",\n        \"0.67 text similarity\",\n        \"0.50 attribute overlap\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"pending\",\n      \"decision\": null,\n      \"anchor\": {\n        \"name\": \"Man Plaid Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 3499,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-plaid-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"Man Short Sleeve Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-short-sleeve-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:85\",\n      \"neighbor_key\": \"http_api:dummyjson.com:83\",\n      \"pair_type\": \"similar\",\n      \"score\": 0.5469,\n      \"confidence\": 0.6527,\n      \"source\": \"embedding\",\n      \"reasons\": [\n        \"same category (mens-shirts)\",\n        \"0.65 text similarity\",\n        \"0.30 attribute overlap\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"pending\",\n      \"decision\": null,\n      \"anchor\": {\n        \"name\": \"Man Plaid Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 3499,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/man-plaid-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"Blue & Black Check Shirt\",\n        \"category\": \"mens-shirts\",\n        \"price_cents\": 2999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mens-shirts/blue-&-black-check-shirt/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    }\n  ],\n  \"dropped\": 0\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const rows = pm.response.json().pairings || [];",
                  "if (rows.length) {",
                  "  pm.collectionVariables.set('pair_anchor', rows[0].anchor_key);",
                  "  pm.collectionVariables.set('pair_neighbor', rows[0].neighbor_key);",
                  "  pm.collectionVariables.set('pair_type', rows[0].pair_type);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Browse the whole catalogue",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&status=all&score=high&category=smartphones&limit=20",
            "description": "No `anchor_key`, three filters: every strong match in one category, whatever its state.\n\nThis is the reporting view rather than the review view -- \"show me everything we are confident about in smartphones\".\n\nOf the catalogue's 1,117 pairs, 356 are high, 403 medium, 358 low."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&status=all&score=high&category=smartphones&limit=20",
                "description": "No `anchor_key`, three filters: every strong match in one category, whatever its state.\n\nThis is the reporting view rather than the review view -- \"show me everything we are confident about in smartphones\".\n\nOf the catalogue's 1,117 pairs, 356 are high, 403 medium, 358 low."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"pairings\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:122\",\n      \"neighbor_key\": \"http_api:dummyjson.com:123\",\n      \"pair_type\": \"upsell\",\n      \"score\": 1.0,\n      \"confidence\": 0.95,\n      \"source\": \"arithmetic\",\n      \"reasons\": [\n        \"3.7x the price\",\n        \"same category (smartphones)\",\n        \"rated 4.12\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"auto\",\n      \"decision\": null,\n      \"anchor\": {\n        \"name\": \"iPhone 6\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 29999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/iphone-6/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"iPhone 13 Pro\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 109999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/iphone-13-pro/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:121\",\n      \"neighbor_key\": \"http_api:dummyjson.com:133\",\n      \"pair_type\": \"upsell\",\n      \"score\": 1.0,\n      \"confidence\": 0.95,\n      \"source\": \"arithmetic\",\n      \"reasons\": [\n        \"3.5x the price\",\n        \"same category (smartphones)\",\n        \"rated 3.06\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"auto\",\n      \"decision\": null,\n      \"anchor\": {\n        \"name\": \"iPhone 5s\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 19999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"Samsung Galaxy S10\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 69999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/samsung-galaxy-s10/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    },\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:134\",\n      \"neighbor_key\": \"http_api:dummyjson.com:123\",\n      \"pair_type\": \"upsell\",\n      \"score\": 1.0,\n      \"confidence\": 0.95,\n      \"source\": \"arithmetic\",\n      \"reasons\": [\n        \"4.4x the price\",\n        \"same category (smartphones)\",\n        \"rated 4.12\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"auto\",\n      \"decision\": null,\n      \"anchor\": {\n        \"name\": \"Vivo S1\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 24999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/vivo-s1/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"iPhone 13 Pro\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 109999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/iphone-13-pro/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    }\n  ],\n  \"dropped\": 0\n}"
            }
          ]
        },
        {
          "name": "What was rejected",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&status=rejected&limit=20",
            "description": "Everything a person turned down, across the catalogue. `decision` is \"rejected\" and `decided_by` records who.\n\nAdd `score=low` to see whether the rejections agree with the scoring, which is the cheapest signal that the matching needs tuning."
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings?tenant_id={{tenant_id}}&status=rejected&limit=20",
                "description": "Everything a person turned down, across the catalogue. `decision` is \"rejected\" and `decided_by` records who.\n\nAdd `score=low` to see whether the rejections agree with the scoring, which is the cheapest signal that the matching needs tuning."
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"pairings\": [\n    {\n      \"anchor_key\": \"http_api:dummyjson.com:121\",\n      \"neighbor_key\": \"http_api:dummyjson.com:104\",\n      \"pair_type\": \"complement\",\n      \"score\": 0.7369,\n      \"confidence\": 0.75,\n      \"source\": \"attribute\",\n      \"reasons\": [\n        \"mobile-accessories is an accessory\",\n        \"different category from smartphones\",\n        \"0.47 text similarity\"\n      ],\n      \"computed_at\": \"2026-08-12 05:21:01.670113+00:00\",\n      \"state\": \"rejected\",\n      \"decision\": \"rejected\",\n      \"anchor\": {\n        \"name\": \"iPhone 5s\",\n        \"category\": \"smartphones\",\n        \"price_cents\": 19999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp\",\n        \"in_stock\": true\n      },\n      \"neighbor\": {\n        \"name\": \"Apple iPhone Charger\",\n        \"category\": \"mobile-accessories\",\n        \"price_cents\": 1999,\n        \"currency\": \"INR\",\n        \"image_url\": \"https://cdn.dummyjson.com/product-images/mobile-accessories/apple-iphone-charger/thumbnail.webp\",\n        \"in_stock\": true\n      }\n    }\n  ],\n  \"dropped\": 0\n}"
            }
          ]
        },
        {
          "name": "422 \u2014 an unknown filter value",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=maybe",
            "description": "Both endpoints reject a value they do not know instead of returning an empty list. An empty list is indistinguishable from \"you are all caught up\", so a typo in your query string would silently look like finished work.\n\nThe same applies to score: ?score=huge returns 422 too."
          },
          "response": [
            {
              "name": "422 Unprocessable Entity",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": "{{base_url}}/catalog/pairings/anchors?tenant_id={{tenant_id}}&status=maybe"
              },
              "status": "Unprocessable Entity",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"detail\": \"unknown status: maybe; expected one of ('pending', 'approved', 'rejected', 'auto', 'all')\"\n}"
            }
          ]
        },
        {
          "name": "Approve a match",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/catalog/pairings/decide",
            "description": "Send as many decisions in one call as you like -- collect the swipes and post them together.\n\nOne decision covers ONE match. Approving a dress with earrings says nothing about that dress's other matches.\n\nDecisions are permanent: pressing the button again in step 2 never undoes them.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"decisions\": [\n    {\n      \"anchor_key\": \"{{pair_anchor}}\",\n      \"neighbor_key\": \"{{pair_neighbor}}\",\n      \"pair_type\": \"{{pair_type}}\",\n      \"decision\": \"approved\",\n      \"decided_by\": \"someone@example.com\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/catalog/pairings/decide",
                "description": "Send as many decisions in one call as you like -- collect the swipes and post them together.\n\nOne decision covers ONE match. Approving a dress with earrings says nothing about that dress's other matches.\n\nDecisions are permanent: pressing the button again in step 2 never undoes them.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"decisions\": [\n    {\n      \"anchor_key\": \"{{pair_anchor}}\",\n      \"neighbor_key\": \"{{pair_neighbor}}\",\n      \"pair_type\": \"{{pair_type}}\",\n      \"decision\": \"approved\",\n      \"decided_by\": \"someone@example.com\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"recorded\": 1\n}"
            }
          ]
        },
        {
          "name": "Reject a match",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/catalog/pairings/decide",
            "description": "Takes effect immediately -- that match stops being shown to shoppers on the very next request.\n\ndecision must be exactly \"approved\" or \"rejected\"; anything else returns 422.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"decisions\": [\n    {\n      \"anchor_key\": \"{{pair_anchor}}\",\n      \"neighbor_key\": \"{{pair_neighbor}}\",\n      \"pair_type\": \"{{pair_type}}\",\n      \"decision\": \"rejected\",\n      \"decided_by\": \"someone@example.com\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/catalog/pairings/decide",
                "description": "Takes effect immediately -- that match stops being shown to shoppers on the very next request.\n\ndecision must be exactly \"approved\" or \"rejected\"; anything else returns 422.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"decisions\": [\n    {\n      \"anchor_key\": \"{{pair_anchor}}\",\n      \"neighbor_key\": \"{{pair_neighbor}}\",\n      \"pair_type\": \"{{pair_type}}\",\n      \"decision\": \"rejected\",\n      \"decided_by\": \"someone@example.com\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"recorded\": 1\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "STEP 6 \u2014 What a shopper sees",
      "item": [
        {
          "name": "Get recommendations for a page",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/recommendations/event",
            "description": "Call this from the storefront widget. Returns up to three products to show.\n\nevent: page_view | dwell | search | add_to_cart | cart_abandon | checkout_start\n\npage.url must be a product page URL already in the catalogue -- that is how it knows which product the shopper is looking at.\n\nALWAYS check `recommend` first. When there is nothing to show it is false and there is no products key at all.\n\nThis endpoint never returns an error status. A suggestion nobody asked for must not break the shopper's page.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"visitor_id\": \"visitor-123\",\n  \"event\": \"dwell\",\n  \"page\": {\n    \"url\": \"https://dummyjson.com/auth/products/121\",\n    \"dwell_seconds\": 60\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/recommendations/event",
                "description": "Call this from the storefront widget. Returns up to three products to show.\n\nevent: page_view | dwell | search | add_to_cart | cart_abandon | checkout_start\n\npage.url must be a product page URL already in the catalogue -- that is how it knows which product the shopper is looking at.\n\nALWAYS check `recommend` first. When there is nothing to show it is false and there is no products key at all.\n\nThis endpoint never returns an error status. A suggestion nobody asked for must not break the shopper's page.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"visitor_id\": \"visitor-123\",\n  \"event\": \"dwell\",\n  \"page\": {\n    \"url\": \"https://dummyjson.com/auth/products/121\",\n    \"dwell_seconds\": 60\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"status\": \"success\",\n  \"recommendation_id\": \"rec_1e34183335\",\n  \"recommend\": true,\n  \"message\": \"Still deciding? You might also like these.\",\n  \"products\": [\n    {\n      \"product_id\": \"http_api:dummyjson.com:106\",\n      \"name\": \"Apple Watch Series 4 Gold\",\n      \"description\": \"\\u2026\",\n      \"image_url\": \"https://cdn.dummyjson.com/\\u2026\",\n      \"url\": \"https://dummyjson.com/products/106\",\n      \"ctas\": [],\n      \"options\": [],\n      \"pair_type\": \"complement\",\n      \"pair_score\": 0.7041\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "When there is nothing to show",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/recommendations/event",
            "description": "Still HTTP 200. Show nothing and carry on.\n\nreason: page_unknown (that URL is not in the catalogue) | no_match | no_rule_matched | feature_off",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"visitor_id\": \"visitor-123\",\n  \"event\": \"dwell\",\n  \"page\": {\n    \"url\": \"https://example.com/unknown-page\",\n    \"dwell_seconds\": 60\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/recommendations/event",
                "description": "Still HTTP 200. Show nothing and carry on.\n\nreason: page_unknown (that URL is not in the catalogue) | no_match | no_rule_matched | feature_off",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tenant_id\": \"{{tenant_id}}\",\n  \"visitor_id\": \"visitor-123\",\n  \"event\": \"dwell\",\n  \"page\": {\n    \"url\": \"https://example.com/unknown-page\",\n    \"dwell_seconds\": 60\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"status\": \"success\",\n  \"recommend\": false,\n  \"reason\": \"page_unknown\"\n}"
            }
          ]
        }
      ]
    }
  ]
}
