openapi: 3.1.0
info:
  title: ClickShop Partner API
  version: '3.0'
  summary: Keep your stock in sync with ClickShop and receive delivered orders in your own system.
  description: |
    Connect your ERP, POS or stock system to your ClickShop store. Your stock, prices and
    availability stay in sync automatically, and every delivered ClickShop order lands in your
    system — no manual entry on either side.

    # How the sync works

    Your system is the **source of truth for stock**. ClickShop keeps a local copy so customers can
    browse and order instantly, and the Partner API keeps that copy accurate.

    **You → ClickShop.** Every time stock, price or availability changes, you send a
    [stock event](#tag/stock-events). ClickShop applies it before answering, so customers see the
    change immediately. *You build: one HTTP call.*

    **ClickShop → you.** When a ClickShop order from your store is delivered, ClickShop sends it
    to you as an [order event](#tag/order-events) so you can record the sale. *You build: one
    endpoint (optional).*

    Products are matched by **SKU** — your product code, usually the barcode. ClickShop stores
    the same SKU on its copy of each product. See [Map your products](#tag/catalog).

    ### Safety nets

    Messages get lost. The sync is layered so a single failure never leaves stock wrong for long:

    - **Stock events** — *seconds.* You push every change. **Required.**
    - **[Changes feed](#tag/changes-feed)** — *≤ 5 minutes.* ClickShop asks "what changed since…?"
      and catches lost events. You host it; recommended.
    - **[Nightly check](#tag/nightly-check)** — *≤ 24 hours.* ClickShop re-checks every mapped
      product overnight. You host it; optional.
    - **[Oversell protection](#description/oversell-protection)** — *always on.* ClickShop ignores
      stale increases that would undo its own sales. Built into ClickShop.
    - **[Live stock check](#tag/live-stock-check)** — *at checkout.* ClickShop asks you to confirm
      stock while the customer orders. You host it; optional.

    ClickShop never calls your servers while customers browse, never sends you customer data, and
    never deletes products based on your data.

    # Quickstart

    **1. Get your API key.** Your ClickShop contact creates an integration for your store and gives
    you a key starting with `csi_`. It is shown once — store it as a secret on your server.

    **2. Read your catalog** to check the key works:

    ```bash
    curl "https://beta.clickshop.ly/api/v3/partners/catalog/items?page_size=5" \
      -H "X-API-Key: $CLICKSHOP_KEY"
    ```

    **3. Send your first stock update:**

    ```bash
    curl -X POST "https://beta.clickshop.ly/api/v3/partners/inventory/events" \
      -H "X-API-Key: $CLICKSHOP_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "event_id": "quickstart-1",
        "type": "stock.updated",
        "updates": [{ "sku": "6241000660410", "current_stock": 12, "update_reason": "adjustment" }]
      }'
    ```

    ```json
    { "data": { "accepted": true, "duplicate": false, "event_id": "quickstart-1" } }
    ```

    **4. Confirm it landed** with [Look up one product](#tag/catalog/GET/api/v3/partners/catalog/items/by-sku):
    `current_stock` is now `12`. Send the same request again and you get `"duplicate": true` —
    nothing is applied twice, which is what makes retries safe.

    > [!TIP]
    > Every endpoint you call has a **Test Request** button: paste your key under
    > *Authentication* and send it from this page. These are real requests — a stock event really
    > changes your store's stock.

    # Authentication

    There are two keys, one per direction.

    **ClickShop API key** — created by ClickShop, starts with `csi_`, 48 characters. You send it on
    every call to ClickShop, in either header:

    ```http
    X-API-Key: csi_…
    Authorization: Bearer csi_…
    ```

    **Outbound key** — created by you. ClickShop sends it on every call to your endpoints:

    ```http
    X-API-Key: <your outbound key>
    ```

    - Your ClickShop key is tied to **one store**. Everything you read and write is scoped to that
      store — you never send a store ID.
    - A missing, wrong or deactivated key returns `401 {"message": "Unauthorized"}`.
    - If you build endpoints for ClickShop to call, send your ClickShop contact your **HTTPS base
      URL** and an **outbound key** you generate. Reject requests without it, using a
      constant-time comparison.
    - Keep keys on servers only — never in an app, website JavaScript or a repository. Ask ClickShop
      to rotate a key if it may have leaked.

    # Integration levels

    Start small, go live, add more later. Each piece is switched on separately by ClickShop.

    | What you build | Basic | Recommended | Full |
    |----------------|:-----:|:-----------:|:----:|
    | Send [stock events](#tag/stock-events) | ✓ | ✓ | ✓ |
    | Read the [catalog](#tag/catalog) to map SKUs | ✓ | ✓ | ✓ |
    | Host a [changes feed](#tag/changes-feed) | – | ✓ | ✓ |
    | Receive [order events](#tag/order-events) | – | ✓ | ✓ |
    | Host the [nightly check](#tag/nightly-check) endpoints | – | – | ✓ |
    | Host a [live stock check](#tag/live-stock-check) | – | – | Optional |
    | **Endpoints you host** | **0** | **2** | **4–5** |

    - **Basic** — nothing to host. But if your sender is down, a lost update isn't recovered until
      that product changes again, and ClickShop orders are still entered by hand.
    - **Recommended** — stock is never more than 5 minutes off, and delivered orders arrive in your
      system automatically. This is what most partners run.
    - **Full** — adds a nightly drift check and lets ClickShop discover products you sell that it
      doesn't have yet.

    # Oversell protection

    ClickShop sells your products too, so your system can briefly report a stock level that doesn't
    include ClickShop's latest sale yet:

    1. 09:00 — you have 5. ClickShop shows 5.
    2. 09:01 — a ClickShop customer orders 2. ClickShop shows **3**.
    3. 09:02 — your system, which hasn't recorded that order yet, sends "stock is **5**".

    Applying that blindly would let ClickShop sell items that are gone. So when an update would
    **raise** stock, ClickShop checks `update_reason`:

    | `update_reason` | When it raises stock, ClickShop… |
    |-----------------|----------------------------------|
    | `restock` | applies it — new goods arrived. |
    | `adjustment` | applies it — someone corrected the count on purpose. |
    | `sync` | applies it — you're sending the authoritative number. |
    | `sale` | applies it if ClickShop hasn't sold the product in the last 24 h. If it has, only when `changed_at` is at or after ClickShop's latest order for it. |
    | *(missing)* | skips it if ClickShop sold the product in the last 24 h; otherwise applies it. |

    Updates that **lower** stock are always applied, and so is any update that brings a product back
    from **0**. When an increase is skipped, `price` and `available` from the same update still
    apply.

    > [!IMPORTANT]
    > Always send `update_reason`, and send `changed_at` (UTC, ISO 8601) on sales.

    # Errors and limits

    Success responses look like `{ "data": …, "meta": … }` (`meta` on lists only). Errors look like
    `{ "message": "…", "errors": { "field": ["…"] } }` (`errors` on validation failures only).
    There is no separate error-code field — use the HTTP status and `message`.

    | Status | Meaning | Retry? |
    |--------|---------|:--:|
    | `401` | Key missing, wrong, or integration deactivated | No — fix the key |
    | `404` | Product not found (`by-sku`) | No |
    | `422` | Invalid body or query — see `errors` | No — fix it, then send a **new** `event_id` |
    | `429` | Rate limit exceeded | Yes — after `Retry-After` seconds |
    | `5xx` / timeout | Temporary problem on ClickShop's side | Yes — same `event_id`, backoff 1 s → 5 s → 30 s → 5 min |

    | Limit | Value |
    |-------|-------|
    | Stock events | 60 requests / minute per IP |
    | Catalog (all three endpoints together) | 120 requests / minute per IP |
    | Updates per stock event | 500 |
    | `event_id` / `sku` length | 128 / 191 characters |
    | Catalog `page_size` / recently-changed `limit` | max 100 / max 50 |
    | Duplicate `event_id` detection | 30 days |

    # Go-live checklist

    **Products**
    - [ ] Compared the ClickShop catalog with your products; mismatched SKUs sent to ClickShop and fixed.
    - [ ] `by-sku` finds 10 random best-sellers with **your** code as `sku`.

    **Stock events**
    - [ ] Sent on every sale, restock and adjustment, with the **new total** (not the difference).
    - [ ] Unique `event_id` per change; retries reuse the same one.
    - [ ] `update_reason` on every update, `changed_at` on sales.
    - [ ] Retries `5xx` / timeouts / `429` (honoring `Retry-After`); never retries `401` / `422`.
    - [ ] Decided whether you send `price` — it overwrites the ClickShop price.

    **Endpoints you host** *(if any)*
    - [ ] HTTPS base URL and outbound key sent to ClickShop; every endpoint returns `401` without the key.
    - [ ] Order events: test orders (`X-Test-Order: true`) kept apart; a repeated `order_id` returns `200` without a second invoice.

    **Switch-over**
    - [ ] Go-live time agreed; test orders removed; old sync scripts and manual ClickShop stock edits stopped.
    - [ ] Staff stop entering ClickShop orders by hand from go-live (if order events are on).

    # Troubleshooting

    **`200` but the stock didn't change.** Check in order: the SKU is mapped (`by-sku` returns your
    exact code as `sku`); the response wasn't `"duplicate": true`; the update wasn't a stale
    increase held back by [oversell protection](#description/oversell-protection) — resend with
    `update_reason: "adjustment"` if it's a real change; the response wasn't `"accepted": false`
    (stock events switched off — contact ClickShop).

    **`422 The given data was invalid.`** The key in `errors` points at the field, e.g.
    `updates.3.current_stock`. Usual causes: decimals or negatives in `current_stock`, `sku` sent as
    a number, `available` sent as a string.

    **Stock drifts from your system.** Check your sender's retries and logs; add a
    [changes feed](#tag/changes-feed); make sure nobody edits stock directly on ClickShop.

    **Same order received twice.** Your endpoint probably answered after the 15 s timeout. Make it
    idempotent on `order_id`.

    **Order never arrived.** Only delivered orders are sent. If your endpoint returned a `4xx` or was
    down for over ~1.5 hours, ClickShop stopped retrying — ask them to resend it.

    When asking ClickShop for help, include the time, endpoint, `event_id` / `order_id`, HTTP
    status and response body — never your API key.

servers:
  - url: 'https://beta.clickshop.ly'
    description: ClickShop API

security:
  - apiKey: []
  - bearer: []

tags:
  - name: Stock events
    x-displayName: Stock events
    description: |
      Tell ClickShop the **current** stock, price and availability of your products. Send an event
      whenever something changes in your system:

      | In your system | `update_reason` |
      |----------------|-----------------|
      | A sale (till, online, a ClickShop order you recorded) | `sale` |
      | A delivery received | `restock` |
      | A stock count, damage, correction | `adjustment` |
      | A scheduled full resend | `sync` |

      **Send the new total, not the difference** — "we now have 12", never "we sold 3". A lost or
      repeated message can then never make the numbers drift.

      **Batching.** Up to 500 updates per event and 60 events per minute. Send one event per
      transaction, or buffer for 2–5 seconds if your system is bursty. For a full resend, split into
      chunks of 500 with their own `event_id`s (`nightly-2026-10-05-part-1`, `-part-2`, …).

      **Retries.** Retry `5xx`, timeouts and `429` with the **same** `event_id` — ClickShop recognizes
      it and never applies it twice. Don't retry `401` or `422`.

      ```js
      const DELAYS = [1_000, 5_000, 30_000, 300_000];

      export async function sendStockEvent(event) {
        for (let attempt = 0; ; attempt++) {
          const res = await fetch('https://beta.clickshop.ly/api/v3/partners/inventory/events', {
            method: 'POST',
            headers: { 'X-API-Key': process.env.CLICKSHOP_KEY, 'Content-Type': 'application/json' },
            body: JSON.stringify(event),
            signal: AbortSignal.timeout(20_000),
          }).catch(() => null); // network error / timeout → retry

          if (res?.ok) return res.json();
          if (res?.status === 401 || res?.status === 422) {
            throw new Error(`Rejected ${event.event_id}: ${res.status} ${await res.text()}`);
          }
          if (attempt >= DELAYS.length) throw new Error(`Gave up on ${event.event_id}`);
          const retryAfter = Number(res?.headers.get('Retry-After')) * 1000;
          await new Promise((r) => setTimeout(r, retryAfter || DELAYS[attempt]));
        }
      }
      ```

      > [!TIP]
      > Write events to an outbox table before sending and mark them sent on `200`. A crash then
      > never loses an update.
  - name: Catalog
    x-displayName: Catalog & SKU mapping
    description: |
      Read-only access to the products ClickShop has for your store, to match them with your own
      product codes. Only **active** products are returned. To change anything, send a
      [stock event](#tag/stock-events).

      Stock events match the ClickShop **SKU exactly** — no fallback to barcode or name. Updates for
      unknown SKUs are skipped (not an error) and reported to the ClickShop team.

      **How to map:**

      1. Page through [List products](#tag/catalog/GET/api/v3/partners/catalog/items) with
         `page_size=100` (a 2,000-product store is 20 requests).
      2. Compare with your products. Where ClickShop's `sku` is empty or different, send the list to
         your ClickShop contact to fix. Products you sell that ClickShop lacks can be added by them —
         products aren't created through the API.
      3. Spot-check codes with [Look up one product](#tag/catalog/GET/api/v3/partners/catalog/items/by-sku).

      ```js
      async function fetchClickShopCatalog() {
        const all = [];
        for (let page = 1; ; page++) {
          const res = await fetch(
            `https://beta.clickshop.ly/api/v3/partners/catalog/items?page=${page}&page_size=100`,
            { headers: { 'X-API-Key': process.env.CLICKSHOP_KEY } },
          );
          const { data, meta } = await res.json();
          all.push(...data);
          if (page >= meta.total_pages) return all;
        }
      }
      ```

      Always send SKUs as JSON **strings** — numbers lose leading zeros.
  - name: Order events
    x-displayName: Order events
    description: |
      **ClickShop → your endpoint.** When a ClickShop order from your store is **delivered**,
      ClickShop sends it to your system once, with lines, prices and discounts — so staff no longer
      type ClickShop orders in by hand.

      Default path: `POST {your_base_url}/api/market/orders/events` (can be changed). Timeout 15 s.

      **The four rules**

      1. **Check the key** — `401` without your outbound key in `X-API-Key`.
      2. **Treat `order_id` as unique** — a repeat must return `2xx` without creating a second
         invoice. Back it with a `UNIQUE` index.
      3. **Return `2xx` only after saving** — ClickShop never resends after a `2xx`.
      4. **Pick the right error** — `5xx` for temporary problems (retried after 1 min, 5 min, 15 min,
         1 h); `4xx` only when retrying can never work (not retried; ClickShop follows up).

      ```js
      app.post('/api/market/orders/events', express.json(), async (req, res) => {
        if (!safeEqual(req.get('X-API-Key'), process.env.CLICKSHOP_OUTBOUND_KEY)) {
          return res.status(401).json({ message: 'Unauthorized' });
        }
        const order = req.body;
        if (order.event !== 'order.delivered') {
          return res.status(422).json({ message: 'Unsupported event' });
        }
        try {
          const existing = await db.invoices.findByClickShopOrder(order.order_id);
          if (existing) return res.json({ ok: true, partner_order_id: existing.number, duplicate: true });

          const invoice = await db.createSale(order, { test: req.get('X-Test-Order') === 'true' });
          return res.json({ ok: true, partner_order_id: invoice.number, duplicate: false });
        } catch (err) {
          return res.status(503).json({ message: 'Try again later' }); // ClickShop retries
        }
      });
      ```

      **Good to know**
      - Only delivered orders are sent. Orders cancelled before delivery never reach you.
      - Cash-on-delivery orders refused at the door are still sent as delivered — ClickShop covers
        that loss.
      - No customer name, phone, address or delivery fee — amounts cover your products only.
      - Test orders carry `X-Test-Order: true`. Keep them apart from real sales.
      - ClickShop already lowered its stock when the customer ordered. When recording the order
        lowers your stock, send that stock event with `update_reason: "sale"` and `changed_at`.

      > [!CAUTION]
      > From go-live, staff must stop entering ClickShop orders by hand — or every sale is deducted
      > twice.
  - name: Changes feed
    x-displayName: Changes feed
    description: |
      **ClickShop → your endpoint, every 5 minutes.** A safety net for lost stock events: ClickShop
      asks which products changed since its last pull and applies them. With it, ClickShop is never
      more than ~5 minutes behind you.

      Default path: `GET {your_base_url}/api/market/products/changed`. Timeout 25 s per page.

      **Tips**
      - Index a `changed_at` column and query `WHERE changed_at >= :since ORDER BY changed_at LIMIT :limit`.
      - Overlap is fine, gaps are not — returning a product twice is harmless.
      - `cursor` can be any string you can read back as `since`, but `since` must also accept a plain
        ISO 8601 timestamp.
      - Include `update_reason` and `changed_at` so oversell protection works as for events.
      - On error, ClickShop retries 5 minutes later from the same `since`. Nothing is lost.
  - name: Nightly check
    x-displayName: Nightly check
    description: |
      **ClickShop → your endpoints, once a night.** ClickShop re-checks every product that has a SKU
      against your system and corrects any drift, then pages through your full catalog to find
      products you sell that aren't on ClickShop yet. Values you return here are treated as
      authoritative (`update_reason: "sync"`) unless you set another reason.

      Default paths: `GET {your_base_url}/api/market/products` and
      `GET {your_base_url}/api/market/products/catalog`.
  - name: Live stock check
    x-displayName: Live stock check
    description: |
      **ClickShop → your endpoint, during checkout.** Optional: ClickShop asks your system to confirm
      stock while the customer places the order, and refuses the order if you say there isn't enough.

      Default path: `POST {your_base_url}/api/market/products/verify`. Timeout **8 s**.

      > [!WARNING]
      > This sits inside the customer's checkout — every millisecond you take, they wait. Most
      > partners don't need it: stock events keep ClickShop accurate within seconds.

      | Your answer | Checkout |
      |-------------|----------|
      | `ok: true` | continues |
      | `ok: false` with `failures` | refused — the customer sees "only N available" for the first failing product |
      | error, non-`2xx` or timeout | continues by default; ClickShop can switch your integration to block instead |

x-tagGroups:
  - name: You call ClickShop
    tags: [Stock events, Catalog]
  - name: ClickShop calls you
    tags: [Order events, Changes feed, Nightly check, Live stock check]

paths:
  /api/v3/partners/inventory/events:
    post:
      tags: [Stock events]
      operationId: sendStockEvent
      summary: Send a stock event
      description: |
        Updates stock, price and/or availability for up to 500 products. Changes are applied
        **before** the response is returned. Idempotent by `event_id`.

        A `200` means the event was accepted — not that every product changed. Updates for unknown
        SKUs, updates held back by [oversell protection](#description/oversell-protection), and
        updates that change nothing are skipped and logged for the ClickShop team.

        **Rate limit:** 60 requests / minute per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StockEvent' }
            examples:
              sale:
                summary: Sale at the till
                value:
                  event_id: pos-2026-10-05-88421
                  type: stock.updated
                  updates:
                    - sku: '6241000660410'
                      current_stock: 11
                      update_reason: sale
                      changed_at: '2026-10-05T09:30:00Z'
                    - sku: '8716200604598'
                      current_stock: 40
                      update_reason: sale
                      changed_at: '2026-10-05T09:30:00Z'
              restock:
                summary: Delivery received
                value:
                  event_id: grn-2026-10-05-17
                  type: stock.updated
                  updates:
                    - sku: '6241000660410'
                      current_stock: 120
                      update_reason: restock
                      changed_at: '2026-10-05T11:02:00Z'
              full:
                summary: All fields
                value:
                  event_id: erp-2026-10-05-000123
                  type: stock.updated
                  updates:
                    - sku: '6241000660410'
                      current_stock: 12
                      price: 8.5
                      available: true
                      update_reason: adjustment
                      changed_at: '2026-10-05T09:30:00Z'
                      name: 'ماء الضيافة 500 مل'
                      category_name: 'مشروبات و عصائر'
              price:
                summary: Price change only
                value:
                  event_id: price-2026-10-05-3
                  type: stock.updated
                  updates:
                    - sku: '6241000660410'
                      price: 9
              hide:
                summary: Hide a discontinued product
                value:
                  event_id: discontinue-2026-10-05-1
                  type: stock.updated
                  updates:
                    - sku: '6241000660410'
                      available: false
      responses:
        '200':
          description: |
            Event received. Check `duplicate` and `accepted`:

            - `accepted: true, duplicate: false` — new event, applied.
            - `accepted: true, duplicate: true` — this `event_id` was already received; nothing re-applied. Treat as success.
            - `accepted: false` — stock events are switched off for your integration; nothing stored. Contact ClickShop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockEventResult' }
              examples:
                applied:
                  summary: Applied
                  value: { data: { accepted: true, duplicate: false, event_id: pos-2026-10-05-88421 } }
                duplicate:
                  summary: Already received
                  value: { data: { accepted: true, duplicate: true, event_id: pos-2026-10-05-88421 } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: |
            The event was rejected; none of its updates were applied. Fix it and send it with a
            **new** `event_id`. Don't retry unchanged.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/ValidationError'
                  - $ref: '#/components/schemas/Error'
              examples:
                invalid:
                  summary: Invalid field
                  value:
                    message: The given data was invalid.
                    errors:
                      updates.0.sku: [The updates.0.sku field is required.]
                      updates.1.current_stock: [The updates.1.current_stock must be at least 0.]
                type:
                  summary: Wrong type
                  value: { message: Unsupported event type. }
                tooMany:
                  summary: More than 500 updates
                  value: { message: Too many updates in one event. }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v3/partners/catalog/items:
    get:
      tags: [Catalog]
      operationId: listCatalogItems
      summary: List products
      description: |
        Paginated list of your store's active products on ClickShop. Use it to map SKUs and to
        compare stock. **Rate limit:** 120 requests / minute per IP, shared with the other catalog
        endpoints.
      parameters:
        - name: page
          in: query
          description: Page number. Stop when it reaches `meta.total_pages`.
          schema: { type: integer, minimum: 1, default: 1 }
        - name: page_size
          in: query
          description: Products per page. Use `100` to export the whole catalog in as few requests as possible.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: q
          in: query
          description: Search in name, SKU and barcode.
          schema: { type: string, maxLength: 120 }
          example: '6241'
        - name: category_id
          in: query
          description: Only products in this ClickShop category.
          schema: { type: integer, minimum: 1 }
        - name: include_subcategories
          in: query
          description: With `category_id`, also include its subcategories. Accepts `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`.
          schema: { type: boolean }
        - name: available
          in: query
          description: '`true` — only products visible to customers. `false` — only hidden ones.'
          schema: { type: boolean }
        - name: sort
          in: query
          description: '`name` A→Z, `-name` Z→A, `-updated_at` most recently changed first.'
          schema: { type: string, enum: [name, -name, -updated_at], default: name }
      responses:
        '200':
          description: A page of products. `meta.filters` appears only when you passed a filter or sort.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/QueryValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v3/partners/catalog/items/by-sku:
    get:
      tags: [Catalog]
      operationId: getCatalogItemBySku
      summary: Look up one product
      description: |
        Finds the active product whose **SKU or barcode** equals `sku`.

        > [!NOTE]
        > Stock events only match the **SKU**. If a product is found by barcode, check that the
        > returned `sku` is the code you send in your events.
      parameters:
        - name: sku
          in: query
          required: true
          schema: { type: string, maxLength: 191 }
          example: '6241000660410'
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/CatalogItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No active product in your store has that SKU or barcode.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { message: Product not found. }
        '422': { $ref: '#/components/responses/QueryValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v3/partners/catalog/items/recently-changed:
    get:
      tags: [Catalog]
      operationId: listRecentlyChangedItems
      summary: Recently changed products
      description: Your most recently updated products on ClickShop, newest first. Handy to confirm your last events landed.
      parameters:
        - name: limit
          in: query
          description: How many products to return.
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
      responses:
        '200':
          description: Products, newest change first.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogItem' }
                  meta:
                    type: object
                    properties:
                      limit: { type: integer, example: 20 }
                      count: { type: integer, example: 20 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/QueryValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

webhooks:
  orderDelivered:
    post:
      tags: [Order events]
      operationId: orderDelivered
      summary: Order delivered
      description: |
        `POST {your_base_url}/api/market/orders/events`

        Sent once per order, when it is delivered (or picked up). Headers sent by ClickShop:

        | Header | Value |
        |--------|-------|
        | `X-API-Key` | Your outbound key |
        | `Idempotency-Key` | `clickshop-order-{order_id}-{event}` |
        | `X-Test-Order` | `true` on test orders only |
      security:
        - outboundKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          example: clickshop-order-6207688-order.delivered
        - name: X-Test-Order
          in: header
          description: Present (`true`) on test orders only. Keep these apart from real sales.
          schema: { type: string, enum: ['true'] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderEvent' }
            example:
              event: order.delivered
              order_id: 6207688
              placed_at: '2026-09-22T19:45:33+02:00'
              delivered_at: '2026-09-22T20:09:17+02:00'
              fulfillment: delivery
              currency: LYD
              payment_method: wallet
              payment_method_group: wallet_or_online
              lines:
                - sku: '8716200604598'
                  item_id: 84609
                  name: 'حليب حكه كوست - 410 جرام'
                  qty: 6
                  unit_price: 7
                  unit_discount: 1
                  line_total: 36
                - sku: '6241000660410'
                  item_id: 12345
                  name: 'ماء الضيافة 500 مل'
                  qty: 4
                  unit_price: 8.5
                  unit_discount: 0
                  line_total: 34
              unmapped_lines: []
              items_subtotal: 76
              items_discount: 6
              items_total: 70
      responses:
        '200':
          description: Saved (or already saved). ClickShop never sends this order again. The body is optional; `partner_order_id` is logged by ClickShop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEventAck' }
        '401':
          description: Wrong or missing outbound key. Not retried.
        '422':
          description: Any `4xx` except 408/423/425/429 — permanent rejection, not retried. Explain why in `message`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: '`5xx`, `408`, `423`, `425`, `429` or a timeout — retried after 1 min, 5 min, 15 min and 1 h (5 attempts in total).'

  changedProducts:
    get:
      tags: [Changes feed]
      operationId: changedProducts
      summary: Changed products
      description: |
        `GET {your_base_url}/api/market/products/changed?since=…&limit=500`

        Return the products whose stock, price or availability changed at or after `since`. While
        you answer `has_more: true` (with rows), ClickShop immediately asks for the next page with
        `since={cursor}` — up to 20 pages per pull.
      security:
        - outboundKey: []
      parameters:
        - name: since
          in: query
          description: ISO 8601 time of ClickShop's last successful pull, **or** the `cursor` you returned on the previous page. Absent only on the very first pull — then return recent changes (e.g. the last hour).
          schema: { type: string }
          example: '2026-10-05T09:25:00+00:00'
        - name: limit
          in: query
          required: true
          description: Maximum rows to return.
          schema: { type: integer, example: 500 }
      responses:
        '200':
          description: Changed products.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChangedProducts' }
        '500':
          description: Any non-`2xx` or timeout (25 s) aborts this pull; nothing is applied and the next pull retries from the same `since`.

  productsBySku:
    get:
      tags: [Nightly check]
      operationId: productsBySku
      summary: Products by SKU
      description: |
        `GET {your_base_url}/api/market/products?skus=…`

        ClickShop goes through every product with a SKU, 100 at a time. Return a row for each SKU
        you know; leave out SKUs you don't know (not an error). A bare JSON array of rows is also
        accepted. Timeout 30 s; a failure stops that night's check.
      security:
        - outboundKey: []
      parameters:
        - name: skus
          in: query
          required: true
          description: Comma-separated list of up to 100 SKUs.
          schema: { type: string }
          example: 6240000319021,6241000660410,8716200604598
      responses:
        '200':
          description: Current state of the requested products.
          content:
            application/json:
              schema:
                type: object
                required: [products]
                properties:
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/PartnerProduct' }

  fullCatalog:
    get:
      tags: [Nightly check]
      operationId: fullCatalog
      summary: Full catalog
      description: |
        `GET {your_base_url}/api/market/products/catalog?page=1&page_size=100`

        Your full product list, page by page, so the ClickShop team can find products that aren't
        mapped yet. It never changes stock by itself. Use a stable sort order (e.g. by SKU) so pages
        don't shift. Timeout 45 s per page.
      security:
        - outboundKey: []
      parameters:
        - name: page
          in: query
          required: true
          schema: { type: integer, minimum: 1, example: 1 }
        - name: page_size
          in: query
          required: true
          schema: { type: integer, maximum: 100, example: 100 }
      responses:
        '200':
          description: One page of your catalog.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerCatalogPage' }

  verifyStock:
    post:
      tags: [Live stock check]
      operationId: verifyStock
      summary: Verify stock
      description: |
        `POST {your_base_url}/api/market/products/verify`

        Answer within 8 seconds whether you can fulfill every line.
      security:
        - outboundKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyRequest' }
      responses:
        '200':
          description: Your verdict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VerifyResponse' }
              examples:
                ok:
                  summary: Enough stock
                  value: { ok: true, failures: [] }
                short:
                  summary: Not enough stock
                  value: { ok: false, failures: [{ sku: '8716200604598', available_qty: 4 }] }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Your ClickShop API key (`csi_…`).
    bearer:
      type: http
      scheme: bearer
      description: 'The same ClickShop API key, sent as `Authorization: Bearer csi_…`.'
    outboundKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: The outbound key you gave ClickShop. ClickShop sends it on every call to your endpoints.

  responses:
    Unauthorized:
      description: API key missing, wrong, or integration deactivated. Don't retry — fix the key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { message: Unauthorized }
    TooManyRequests:
      description: Rate limit exceeded. Wait `Retry-After` seconds, then retry.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema: { type: integer, example: 42 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { message: Too Many Attempts. }
    QueryValidationError:
      description: A query parameter is invalid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
          example:
            message: Validation failed.
            errors:
              page_size: [The page size must not be greater than 100.]

  schemas:
    Error:
      type: object
      required: [message]
      properties:
        message: { type: string, example: Unauthorized }

    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message: { type: string, example: The given data was invalid. }
        errors:
          type: object
          description: Field path → list of problems.
          additionalProperties:
            type: array
            items: { type: string }

    StockEvent:
      type: object
      required: [event_id, type, updates]
      properties:
        event_id:
          type: string
          maxLength: 128
          description: Your unique ID for this message. A repeat is ignored, so retries are safe. Use a transaction ID, row ID or UUID.
          example: erp-2026-10-05-000123
        type:
          type: string
          enum: [stock.updated]
        updates:
          type: array
          minItems: 1
          maxItems: 500
          items: { $ref: '#/components/schemas/StockUpdate' }

    StockUpdate:
      type: object
      required: [sku]
      description: Only `sku` is required. Leave out any field you don't want to change.
      properties:
        sku:
          type: string
          maxLength: 191
          description: Your product code, matched **exactly** to the ClickShop SKU in your store.
          example: '6241000660410'
        current_stock:
          type: [integer, 'null']
          minimum: 0
          description: New **total** on hand. `stock` is accepted as an alias.
          example: 12
        price:
          type: [number, 'null']
          minimum: 0
          description: Selling price in LYD. **Becomes the price customers see** — leave it out if ClickShop manages your prices.
          example: 8.5
        available:
          type: [boolean, 'null']
          description: '`false` hides the product from customers; `true` shows it again.'
          example: true
        update_reason:
          type: string
          enum: [sale, restock, adjustment, sync]
          description: Why stock changed. Drives [oversell protection](#description/oversell-protection). Other values are ignored.
          example: sale
        changed_at:
          type: string
          format: date-time
          description: When the change happened in your system, ideally UTC.
          example: '2026-10-05T09:30:00Z'
        name:
          type: string
          description: Your product name — helps ClickShop identify unmapped SKUs.
          example: 'ماء الضيافة 500 مل'
        category_name:
          type: string
          description: Your category name — same purpose.
          example: 'مشروبات و عصائر'
        barcode:
          type: string
          description: Only if different from `sku`. Informational.

    StockEventResult:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [accepted, duplicate, event_id]
          properties:
            accepted:
              type: boolean
              description: '`false` only when stock events are switched off for your integration.'
            duplicate:
              type: boolean
              description: '`true` when this `event_id` was already received.'
            event_id: { type: string }

    CatalogItem:
      type: object
      required: [item_id, sku, barcode, name, price, current_stock, available, category_id, category_name, updated_at]
      properties:
        item_id:
          type: integer
          description: ClickShop product ID. Also appears on order events.
          example: 12345
        sku:
          type: [string, 'null']
          description: The code stock events are matched on. `null` if not set yet.
          example: '6241000660410'
        barcode:
          type: [string, 'null']
          example: null
        name:
          type: string
          example: 'ماء الضيافة 500 مل'
        price:
          type: number
          description: Current price, LYD.
          example: 8.5
        current_stock:
          type: integer
          example: 3
        available:
          type: boolean
          description: '`false` = hidden from customers.'
          example: true
        category_id:
          type: [integer, 'null']
          example: 1044
        category_name:
          type: [string, 'null']
          example: 'مشروبات و عصائر'
        updated_at:
          type: [string, 'null']
          format: date-time
          example: '2026-10-05T09:30:02+00:00'

    CatalogPage:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/CatalogItem' }
        meta:
          type: object
          required: [page, page_size, total, total_pages]
          properties:
            page: { type: integer, example: 1 }
            page_size: { type: integer, example: 50 }
            total: { type: integer, example: 1842 }
            total_pages: { type: integer, example: 37 }
            filters:
              type: object
              description: Echo of the filters and sort you passed.
              example: { q: '6241', sort: name }

    OrderEvent:
      type: object
      required: [event, order_id, placed_at, delivered_at, fulfillment, currency, payment_method, payment_method_group, lines, unmapped_lines, items_subtotal, items_discount, items_total]
      properties:
        event:
          type: string
          enum: [order.delivered]
        order_id:
          type: integer
          description: ClickShop order number. **Unique** — use it to detect repeats.
          example: 6207688
        placed_at:
          type: string
          format: date-time
          description: When the customer placed the order. ISO 8601 with offset — parse the offset, don't assume it.
          example: '2026-09-22T19:45:33+02:00'
        delivered_at:
          type: string
          format: date-time
          description: When the order was delivered or picked up. ISO 8601 with offset.
          example: '2026-09-22T20:09:17+02:00'
        fulfillment:
          type: string
          enum: [delivery, pickup]
        currency:
          type: string
          enum: [LYD]
        payment_method:
          type: string
          description: How the customer paid ClickShop. Informational.
          example: wallet
        payment_method_group:
          type: string
          enum: [cash, cash_and_wallet, wallet_or_online]
          description: Informational.
        lines:
          type: array
          description: Products matched to your SKUs. The same SKU can appear on several lines.
          items: { $ref: '#/components/schemas/OrderLine' }
        unmapped_lines:
          type: array
          description: Products with no SKU on ClickShop — same fields without `sku`. Usually empty.
          items: { $ref: '#/components/schemas/UnmappedOrderLine' }
        items_subtotal:
          type: number
          description: Σ `unit_price × qty`, all lines.
          example: 42
        items_discount:
          type: number
          description: Σ `unit_discount × qty`.
          example: 6
        items_total:
          type: number
          description: '`items_subtotal − items_discount`. No delivery fee.'
          example: 36

    OrderLine:
      allOf:
        - type: object
          required: [sku]
          properties:
            sku:
              type: string
              description: Your product code, as mapped on ClickShop.
              example: '8716200604598'
        - $ref: '#/components/schemas/UnmappedOrderLine'

    UnmappedOrderLine:
      type: object
      required: [item_id, name, qty, unit_price, unit_discount, line_total]
      properties:
        item_id:
          type: integer
          description: ClickShop product ID.
          example: 84609
        name:
          type: string
          description: Product name at the time of the order.
          example: 'حليب حكه كوست - 410 جرام'
        qty:
          type: integer
          example: 6
        unit_price:
          type: number
          description: Shelf price per unit, LYD.
          example: 7
        unit_discount:
          type: number
          description: Discount per unit, LYD. `0` when none.
          example: 1
        line_total:
          type: number
          description: '`(unit_price − unit_discount) × qty` — what the customer paid for the line.'
          example: 36

    OrderEventAck:
      type: object
      properties:
        ok: { type: boolean, example: true }
        partner_order_id:
          type: string
          description: Your invoice or order reference.
          example: INV-2026-001234
        duplicate:
          type: boolean
          description: '`true` if you already had this order.'
          example: false

    PartnerProduct:
      type: object
      required: [sku]
      description: |
        Same fields as a stock event update. Rows without `sku` are ignored. Leave out what you
        don't want ClickShop to change (e.g. `price` if ClickShop manages prices).
      properties:
        sku: { type: string, example: '6241000660410' }
        current_stock:
          type: integer
          minimum: 0
          description: '`stock` is accepted as an alias.'
          example: 13
        price: { type: number, example: 8.5 }
        available: { type: boolean, example: true }
        changed_at: { type: string, format: date-time, example: '2026-10-05T07:26:12Z' }
        update_reason: { type: string, enum: [sale, restock, adjustment, sync], example: sale }
        name: { type: string, example: 'ماء الضيافة 500 مل' }
        category_name: { type: string, example: 'مشروبات و عصائر' }
        barcode: { type: string }

    ChangedProducts:
      type: object
      required: [changes, has_more]
      properties:
        changes:
          type: array
          description: Only products changed since `since` — not your whole catalog.
          items: { $ref: '#/components/schemas/PartnerProduct' }
        cursor:
          type: string
          description: Where the next page starts. Sent back to you as `since`.
          example: '2026-10-05T07:26:12Z'
        has_more:
          type: boolean
          example: false

    PartnerCatalogPage:
      type: object
      required: [products, page, page_size, total, total_pages]
      properties:
        products:
          type: array
          items: { $ref: '#/components/schemas/PartnerProduct' }
        page: { type: integer, example: 1 }
        page_size: { type: integer, example: 100 }
        total: { type: integer, example: 2472 }
        total_pages: { type: integer, example: 25 }

    VerifyRequest:
      type: object
      required: [lines]
      properties:
        lines:
          type: array
          items:
            type: object
            required: [sku, qty]
            properties:
              sku: { type: string, example: '8716200604598' }
              qty: { type: integer, example: 6 }

    VerifyResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
          description: '`true` if every line can be fulfilled.'
        failures:
          type: array
          items:
            type: object
            required: [sku, available_qty]
            properties:
              sku: { type: string }
              available_qty:
                type: integer
                description: How many you actually have. Shown to the customer.
