{
  "info": {
    "_postman_id": "3c70e5d8-2a41-4b96-9f03-6e2d8b7a1c55",
    "name": "Shamz Catalogue Push — Line Phones",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
    "description": "The three calls needed to send Line Phones' catalogue to shamz. Nothing else.\n\nYour system sends us products, prices and stock. We never call back into your system — there is no credential for you to issue us, and no endpoint of yours we need. The assistant answers customers from the last thing you sent, and tells them when it was sent.\n\n## Setting up, once\n\n1. The merchant creates a Push API key in their shamz dashboard (**E-commerce → Push API**) or in the shamz app (**Shop → API keys**).\n2. The secret is shown **once** and stored hashed — we cannot recover it. If it is lost, create another and switch the old one off.\n3. Paste it into this collection's `apiKey` variable (Variables tab), or set it in an environment.\n\nKeys can be switched off at any time and stop working on the very next request, so the merchant can revoke one without waiting for anything to expire.\n\n## Running it the first time\n\nRun the requests in order. **Send the full catalogue before any stock update** — an update for a product we have not seen yet is reported back to you in `unknown` rather than silently created.\n\n## The one thing to get right\n\n`PUT /catalog` is your **whole** catalogue, not a page of it. Anything we hold that the payload leaves out is taken off sale. If your export ever paginates, assemble the pages before sending, or send the products you still sell in one request.\n\n## Suggested cadence\n\n| | when |\n|---|---|\n| `PUT /catalog` | nightly, and after any product is added or withdrawn |\n| `PATCH /items` | whenever a price or stock figure moves — as often as you like |\n| `GET /status` | after a push, to confirm what landed |\n\nA stock update never takes a product off sale, so a withdrawn phone stays listed until your next full catalogue. That is the reason for the nightly push.\n\nFull reference: https://shamz.ai/developers-guide"
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://api.shamz.ai",
      "type": "string"
    },
    {
      "key": "apiKey",
      "value": "",
      "type": "string"
    },
    {
      "key": "lastPushAt",
      "value": "",
      "type": "string"
    }
  ],
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{apiKey}}",
        "type": "string"
      }
    ]
  },
  "item": [
    {
      "name": "1. Send the full catalogue",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('accepted (202)', () => pm.response.to.have.status(202));",
              "",
              "const b = pm.response.json();",
              "console.log(`accepted ${b.accepted}  created ${b.created}  updated ${b.updated}  taken off sale ${b.deactivated}`);",
              "",
              "pm.test('no phone was rejected', () => {",
              "  if (b.rejected && b.rejected.length) {",
              "    console.log('REJECTED — these were not saved:', JSON.stringify(b.rejected));",
              "  }",
              "  pm.expect(b.rejected, 'rejected rows: see the console for the reason').to.be.an('array').that.is.empty;",
              "});",
              "",
              "pm.test('every phone can appear in a WhatsApp carousel', () => {",
              "  if (b.warnings && Object.keys(b.warnings).length) {",
              "    console.log('NO PHOTO OR NO LINK — these will be described in words, not shown as a card:', JSON.stringify(b.warnings));",
              "  }",
              "  pm.expect(b.carousel_ready, 'some products are missing an image or a url').to.eql(b.accepted);",
              "});",
              "",
              "// Images are fetched after this reply, so run request 3 in a few",
              "// seconds to see whether they actually arrived.",
              "if (b.images_pending) {",
              "  console.log(`${b.images_pending} image(s) still being fetched — run \"3. Check what shamz holds\" shortly.`);",
              "}",
              "",
              "pm.collectionVariables.set('lastPushAt', new Date().toISOString());"
            ]
          }
        }
      ],
      "request": {
        "method": "PUT",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/partners/v1/catalog",
          "host": [
            "{{baseUrl}}"
          ],
          "path": [
            "api",
            "partners",
            "v1",
            "catalog"
          ]
        },
        "description": "Replace the two example phones below with your real export.\n\n### Required on every phone\n\n- `external_id` — your own product id. This is how we match a later price or stock update to the right phone, so it must stay the same for the life of the product.\n- `name`\n- `price` — a number, like `389.5`, not `\"389.5\"`\n- `currency` — `\"KWD\"`. An unrecognised code is rejected rather than assumed, because guessing it would reprice the shop into another country. KWD is shown to customers with three decimals, so `12.5` reads as `KWD 12.500`.\n\n### Strongly recommended\n\n- `url` and `images` — **a phone with no photo or no link cannot appear in a WhatsApp carousel.** It can still be found and described, but it will not be shown as a card, which is the thing customers tap. Use `https` links; we download each photo once and serve our own copy, so your site is not hit by shoppers.\n\n### Worth sending for a phone shop\n\n- `stock_qty` — the exact figure. The assistant answers from it and says when it was sent.\n- `specs` — a flat set of name/value pairs. This is what lets the assistant answer \"does it take two SIMs\", \"how much storage\", \"is there a warranty\" instead of only listing the phone. Leave out anything you do not know rather than sending \"not specified\".\n- `spec_text` — anything that is not a neat name/value pair.\n- `shop.knowledge` — delivery, returns and warranty policy, asked and answered. Send it whenever it changes; matching questions are replaced, not duplicated.\n- `sequence` — Any number that goes up works. Unix **seconds** (`1792008852`) or **milliseconds** (`1792008852038`) are both fine — we store it as a 64-bit integer, so `Date.now()` and `time()` are equally safe. It only ever skips an *update*; a full catalogue is always applied. It is only used to ignore a *late* stock update, never to skip a full catalogue.\n\nLimits: 8 MB and 2,000 phones per request, one full catalogue every 5 minutes.\n\n> The two example phones use placeholder photos from a public image service so that your very first run goes green and proves the whole pipeline — including us re-hosting each photo. Replace the whole `items` array with your real export; the photo URLs must be `https` and reachable without a login.",
        "body": {
          "mode": "raw",
          "options": {
            "raw": {
              "language": "json"
            }
          },
          "raw": "{\n  \"shop\": {\n    \"knowledge\": [\n      {\n        \"question\": \"Do you deliver across Kuwait?\",\n        \"answer\": \"Yes, delivery across Kuwait within 24 hours. Orders over KWD 50 ship free.\"\n      },\n      {\n        \"question\": \"What is the warranty on phones?\",\n        \"answer\": \"All phones carry a 1-year official warranty from the local agent.\"\n      },\n      {\n        \"question\": \"Can I return a phone?\",\n        \"answer\": \"Unopened phones can be returned within 7 days with the receipt.\"\n      }\n    ]\n  },\n  \"items\": [\n    {\n      \"external_id\": \"LP-IP16PM-256\",\n      \"name\": \"iPhone 16 Pro Max 256GB\",\n      \"description\": \"Titanium finish, A18 Pro chip, 256GB storage.\",\n      \"price\": 429.5,\n      \"currency\": \"KWD\",\n      \"stock_qty\": 7,\n      \"url\": \"https://linephones.com/p/iphone-16-pro-max-256\",\n      \"images\": [\n        \"https://picsum.photos/seed/lp-ip16pm-1/800/800\",\n        \"https://picsum.photos/seed/lp-ip16pm-2/800/800\"\n      ],\n      \"specs\": {\n        \"RAM\": \"8GB\",\n        \"Storage\": \"256GB\",\n        \"Screen\": \"6.9 inch\",\n        \"Warranty\": \"1 year\",\n        \"eSIM\": \"Supported\"\n      },\n      \"spec_text\": \"Dual eSIM with no physical SIM tray on GCC units. USB-C.\",\n      \"sequence\": {{$timestamp}}\n    },\n    {\n      \"external_id\": \"LP-S25U-512\",\n      \"name\": \"Samsung Galaxy S25 Ultra 512GB\",\n      \"price\": 445.0,\n      \"currency\": \"KWD\",\n      \"stock_qty\": 3,\n      \"url\": \"https://linephones.com/p/galaxy-s25-ultra-512\",\n      \"images\": [\"https://picsum.photos/seed/lp-s25u-1/800/800\"],\n      \"specs\": {\n        \"RAM\": \"12GB\",\n        \"Storage\": \"512GB\",\n        \"Screen\": \"6.9 inch\",\n        \"Warranty\": \"1 year\"\n      },\n      \"sequence\": {{$timestamp}}\n    }\n  ]\n}"
        }
      },
      "response": [
        {
          "name": "202 — both phones saved, photos still arriving",
          "status": "Accepted",
          "code": 202,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"accepted\": 2,\n  \"created\": 2,\n  \"updated\": 0,\n  \"deactivated\": 0,\n  \"rejected\": [],\n  \"warnings\": {},\n  \"carousel_ready\": 2,\n  \"images_pending\": 3\n}"
        }
      ]
    },
    {
      "name": "2. Update a price or stock figure",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('accepted (200)', () => pm.response.to.have.status(200));",
              "",
              "const b = pm.response.json();",
              "",
              "pm.test('shamz recognised every phone', () => {",
              "  if (b.unknown && b.unknown.length) {",
              "    console.log('NOT IN THE CATALOGUE — send request 1 first:', b.unknown);",
              "  }",
              "  pm.expect(b.unknown, 'these external_ids are not in the catalogue yet').to.be.an('array').that.is.empty;",
              "});",
              "",
              "pm.test('the update was applied, not ignored as late', () => {",
              "  const stale = Object.entries(b.warnings || {}).filter(([, w]) => w.includes('stale_ignored'));",
              "  if (stale.length) {",
              "    console.log('IGNORED as older than what shamz already holds:', stale.map(([k]) => k));",
              "    console.log('This means a newer update arrived first. Check that `sequence` always increases.');",
              "  }",
              "  pm.expect(stale.length, 'some updates were older than what shamz holds').to.eql(0);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "PATCH",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/partners/v1/items",
          "host": [
            "{{baseUrl}}"
          ],
          "path": [
            "api",
            "partners",
            "v1",
            "items"
          ]
        },
        "description": "The cheap, frequent call. Send it whenever a price or a stock figure moves — up to 60 times a minute.\n\n**Figures are absolute.** `\"stock_qty\": 2` means two left in total, not two fewer than before.\n\n**This never takes a phone off sale.** A phone you leave out of this request is untouched — only a full catalogue retires anything. So if a phone is discontinued, it stays listed until your next `PUT /catalog`.\n\nSend only what changed. `external_id` plus `price`, `stock_qty` or `in_stock` is enough; there is no need to resend names, photos or specs.\n\n### Why `sequence` matters here\n\nIf your system retries a request and the retry arrives *after* a newer update, the old price would overwrite the new one. `sequence` prevents that: we ignore anything older than what we already hold, and tell you we did. Any number that goes up works. Unix **seconds** (`1792008852`) or **milliseconds** (`1792008852038`) are both fine — we store it as a 64-bit integer, so `Date.now()` and `time()` are equally safe. It only ever skips an *update*; a full catalogue is always applied.",
        "body": {
          "mode": "raw",
          "options": {
            "raw": {
              "language": "json"
            }
          },
          "raw": "{\n  \"items\": [\n    { \"external_id\": \"LP-IP16PM-256\", \"stock_qty\": 4, \"sequence\": {{$timestamp}} },\n    { \"external_id\": \"LP-S25U-512\", \"price\": 429.0, \"stock_qty\": 0, \"sequence\": {{$timestamp}} }\n  ]\n}"
        }
      },
      "response": [
        {
          "name": "200 — both applied",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"accepted\": 2,\n  \"rejected\": [],\n  \"warnings\": {},\n  \"unknown\": []\n}"
        }
      ]
    },
    {
      "name": "3. Check what shamz holds",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('reachable (200)', () => pm.response.to.have.status(200));",
              "",
              "const b = pm.response.json();",
              "const run = b.last_run || {};",
              "",
              "console.log(`live ${b.items_active}  off sale ${b.items_inactive}  can be shown as a card ${b.carousel_ready}`);",
              "console.log(`last full catalogue ${b.last_full_push_at}  last update ${b.last_update_at}`);",
              "",
              "// Photos are fetched AFTER the push answers, so this record can be older",
              "// than the push we just made. Judging it then would pass on stale data —",
              "// which is exactly what it did before this guard was added.",
              "const pushedAt = pm.collectionVariables.get('lastPushAt');",
              "const imagesAt = run.images_at;",
              "const coversThisPush = !pushedAt || (imagesAt && new Date(imagesAt) >= new Date(pushedAt));",
              "",
              "if (!coversThisPush) {",
              "  console.log('Photos were still being fetched when this ran. Wait a few seconds and send this request again.');",
              "  pm.test.skip('every photo was fetched — not reported yet, re-run this request');",
              "} else {",
              "  pm.test('every photo was fetched', () => {",
              "    if (run.image_errors && Object.keys(run.image_errors).length) {",
              "      console.log('PHOTOS WE COULD NOT FETCH (by your external_id):', JSON.stringify(run.image_errors));",
              "      console.log('Usual causes: the link is behind a login, it is http rather than https, or it 404s.');",
              "    }",
              "    pm.expect(run.images_skipped || 0, 'some photos could not be fetched — see the console').to.eql(0);",
              "  });",
              "}",
              "",
              "pm.test('the catalogue is not stale', () => {",
              "  pm.expect(b.last_full_push_at, 'no full catalogue has ever been received').to.not.eql(null);",
              "  const hours = (Date.now() - new Date(b.last_full_push_at).getTime()) / 3600000;",
              "  pm.expect(hours, 'the last full catalogue is over 48h old').to.be.below(48);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "GET",
        "header": [],
        "url": {
          "raw": "{{baseUrl}}/api/partners/v1/status",
          "host": [
            "{{baseUrl}}"
          ],
          "path": [
            "api",
            "partners",
            "v1",
            "status"
          ]
        },
        "description": "Call this after a push to confirm what landed. It is the whole of your debugging — you should never need to ask us what we received.\n\n- `items_active` / `items_inactive` — what is on sale and what has been retired\n- `carousel_ready` — how many phones can be shown as a tappable card. Lower than `items_active` means some are missing a photo or a link.\n- `last_full_push_at` / `last_update_at` — when we last heard from you\n- `last_run` — the outcome of the most recent push, including anything rejected\n\n### Where photo failures show up\n\nPhotos are fetched **after** `PUT /catalog` answers, so the push can report success while a photo later fails. `last_run.images_stored`, `images_skipped` and `image_errors` appear here a few seconds afterwards. `image_errors` is keyed by your own `external_id`, so you can find the product directly.\n\nUp to 120 calls a minute."
      },
      "response": [
        {
          "name": "200 — everything landed",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"items_active\": 2,\n  \"items_inactive\": 0,\n  \"carousel_ready\": 2,\n  \"last_full_push_at\": \"2026-01-14T15:17:02Z\",\n  \"last_update_at\": \"2026-01-14T15:41:20Z\",\n  \"last_run\": {\n    \"kind\": \"snapshot\",\n    \"at\": \"2026-01-14T15:17:02Z\",\n    \"accepted\": 2,\n    \"created\": 2,\n    \"updated\": 0,\n    \"deactivated\": 0,\n    \"rejected\": [],\n    \"warnings\": {},\n    \"images_stored\": 3,\n    \"images_skipped\": 0,\n    \"image_errors\": {}\n  }\n}"
        },
        {
          "name": "200 — a photo could not be fetched",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"items_active\": 2,\n  \"items_inactive\": 0,\n  \"carousel_ready\": 1,\n  \"last_full_push_at\": \"2026-01-14T15:17:02Z\",\n  \"last_update_at\": null,\n  \"last_run\": {\n    \"kind\": \"snapshot\",\n    \"at\": \"2026-01-14T15:17:02Z\",\n    \"accepted\": 2,\n    \"created\": 0,\n    \"updated\": 2,\n    \"deactivated\": 0,\n    \"rejected\": [],\n    \"warnings\": {},\n    \"images_stored\": 2,\n    \"images_skipped\": 1,\n    \"image_errors\": { \"LP-S25U-512\": \"HTTP 404\" }\n  }\n}"
        }
      ]
    },
    {
      "name": "4. Send a cart (optional — abandoned-cart follow-ups)",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "const b = pm.response.json();",
              "",
              "// The merchant has to switch cart follow-ups on. Until they do, this",
              "// endpoint answers normally and stores nothing — so check this before",
              "// concluding your integration works.",
              "pm.test('cart follow-ups are switched on for this shop', () => {",
              "  if (b.recovery_enabled === false) {",
              "    console.log(b.note || 'Cart recovery is off — ask the merchant to enable it.');",
              "  }",
              "  pm.expect(b.recovery_enabled, 'the merchant has not enabled cart follow-ups yet').to.eql(true);",
              "});",
              "",
              "pm.test('the cart was stored', () => {",
              "  if (b.rejected && b.rejected.length) {",
              "    console.log('REJECTED:', JSON.stringify(b.rejected));",
              "  }",
              "  pm.expect(b.rejected).to.be.an('array').that.is.empty;",
              "  pm.expect(b.accepted).to.be.above(0);",
              "});",
              "",
              "if (b.warnings && Object.keys(b.warnings).length) {",
              "  console.log('warnings:', JSON.stringify(b.warnings));",
              "}"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/partners/v1/carts",
          "host": [
            "{{baseUrl}}"
          ],
          "path": [
            "api",
            "partners",
            "v1",
            "carts"
          ]
        },
        "description": "The current contents of a shopping cart. Send it whenever the cart changes — the contents you send replace what we held, so a retry is harmless and there is no need to send \"added one of these\".\n\n**Upsert only.** A cart you leave out of this call is untouched. This is the opposite of `PUT /catalog`, deliberately: carts appear and vanish constantly, and \"anything omitted is gone\" would wipe a live basket every time you sent one update.\n\n### Two required fields, and why\n\n- `recovery_url` — the reminder **is** a link back to the cart. Without it the message could only tell a shopper to go and find their own basket.\n- `customer_phone` **or** `customer_email` — without one there is nobody to remind.\n\nA cart missing either is rejected rather than stored, so you never accumulate carts that look healthy in `/status` and can never send anything.\n\n`discount_code` is optional and is **your** code, honoured by **your** checkout. We never mint one for a pushed shop — a code from our side would not exist at your checkout, and handing a shopper a discount that fails is worse than offering none.\n\n300 requests a minute. The merchant must switch cart follow-ups on before anything is stored; until then this answers normally and tells you so.",
        "body": {
          "mode": "raw",
          "options": {
            "raw": {
              "language": "json"
            }
          },
          "raw": "{\n  \"carts\": [\n    {\n      \"external_id\": \"CART-9911\",\n      \"recovery_url\": \"https://linephones.com/cart/9911\",\n      \"customer_phone\": \"+96555512345\",\n      \"total_amount\": 429.5,\n      \"currency\": \"KWD\",\n      \"items\": [\n        {\n          \"external_id\": \"LP-IP16PM-256\",\n          \"name\": \"iPhone 16 Pro Max 256GB\",\n          \"quantity\": 1,\n          \"price\": 429.5\n        }\n      ]\n    }\n  ]\n}"
        }
      },
      "response": [
        {
          "name": "200 — stored",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"accepted\": 1,\n  \"rejected\": [],\n  \"warnings\": {},\n  \"created\": 1,\n  \"updated\": 0,\n  \"recovery_enabled\": true\n}"
        },
        {
          "name": "200 — but follow-ups are switched off, so nothing was stored",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"accepted\": 0,\n  \"rejected\": [],\n  \"warnings\": {},\n  \"created\": 0,\n  \"updated\": 0,\n  \"recovery_enabled\": false,\n  \"note\": \"Cart recovery is switched off for this shop, so nothing was stored. The merchant enables it in their shamz dashboard under the shop's Skill settings.\"\n}"
        }
      ]
    },
    {
      "name": "5. Send a placed order (optional)",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "const b = pm.response.json();",
              "",
              "pm.test('the order was accepted', () => {",
              "  if (b.rejected && b.rejected.length) console.log('REJECTED:', JSON.stringify(b.rejected));",
              "  pm.expect(b.rejected).to.be.an('array').that.is.empty;",
              "});",
              "",
              "// Not a failure. Most orders are not recovered carts — this is reported",
              "// so that when you DO expect a match and do not get one, you can see it.",
              "if (b.no_cart_matched && b.no_cart_matched.length) {",
              "  console.log('no cart matched these orders:', b.no_cart_matched);",
              "}",
              "console.log(`carts closed by this call: ${b.carts_closed}`);"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/partners/v1/orders",
          "host": [
            "{{baseUrl}}"
          ],
          "path": [
            "api",
            "partners",
            "v1",
            "orders"
          ]
        },
        "description": "An order was placed.\n\nTwo jobs:\n\n1. It stops a shopper who has already bought from being chased about the cart they bought from.\n2. It makes the follow-ups measurable. The cart records which reminder was the last one sent before the order arrived, so the merchant sees which nudge earned the sale instead of guessing.\n\nSend **every** order, not only ones you think came from a cart. Most will not match, and the response lists those in `no_cart_matched` — that is information, not an error.\n\n`cart_external_id` is the surest match. Without it we fall back to the phone or email, which still works but can only match a cart from the same shopper.\n\n300 requests a minute.",
        "body": {
          "mode": "raw",
          "options": {
            "raw": {
              "language": "json"
            }
          },
          "raw": "{\n  \"orders\": [\n    {\n      \"external_id\": \"SO-55021\",\n      \"cart_external_id\": \"CART-9911\",\n      \"order_number\": \"55021\",\n      \"customer_phone\": \"+96555512345\",\n      \"total_amount\": 429.5,\n      \"currency\": \"KWD\"\n    }\n  ]\n}"
        }
      },
      "response": [
        {
          "name": "200 — order recorded and the cart it came from closed",
          "status": "OK",
          "code": 200,
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"accepted\": 1,\n  \"rejected\": [],\n  \"warnings\": {},\n  \"carts_closed\": 1,\n  \"no_cart_matched\": []\n}"
        }
      ]
    }
  ]
}