# shamz.ai Catalogue Push API

Machine-readable reference for the partner API, written to be pasted into a
coding agent or fetched directly. Everything here is also on
https://shamz.ai/developers-guide — this file is the same contract without the
page around it.

**Base URL:** `https://api.shamz.ai`
**Always use `https`.** A plain `http` request is redirected rather than
refused, which means the key has already crossed the network in the clear
before the redirect is followed.

## What this is for

A merchant's own software sends us their catalogue instead of us reading
theirs. Use it when the merchant's system cannot expose an API to us, or when
they would rather it did not. The assistant then answers customers from what
you send — product names, prices, stock, specifications — and tells the
customer when that data was last sent.

We never call back into your system. There is no credential for you to issue
us and no endpoint of yours we need.

## Authentication

A bearer key per merchant, issued by the merchant:

```
Authorization: Bearer shamz_live_<16 hex chars>_<43 chars>
```

The merchant creates it in their shamz dashboard under **E-commerce → Push
API**, or in the shamz app under **Shop → API keys**. The secret is shown once
and stored hashed — it cannot be recovered. If it is lost, create another and
switch the old one off.

Several keys can be active at once, so rotation needs no downtime. Switching a
key off takes effect on the very next request.

Every authentication failure — unknown key, wrong secret, revoked key,
malformed token, missing header — returns the same response:

```
HTTP 401
{"detail": "Invalid API key."}
```

It never says which of those it was.

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| `PUT` | `/api/partners/v1/catalog` | The whole catalogue. Anything omitted is retired. |
| `PATCH` | `/api/partners/v1/items` | Absolute price and stock for products we already hold. |
| `GET` | `/api/partners/v1/status` | What we hold, and how the last push went. |
| `POST` | `/api/partners/v1/carts` | Current contents of a cart (abandoned-cart follow-ups). |
| `POST` | `/api/partners/v1/orders` | An order was placed. |

---

## `PUT /api/partners/v1/catalog`

**This is the whole catalogue, not a page of it.** Any product we hold that
the payload omits is deactivated — the row survives so past orders still
resolve, but the assistant stops offering it. If your export paginates,
assemble the pages before sending.

Answers **202**, not 200: the product rows are committed before we answer, but
images are fetched afterwards.

### Item fields

| Field | Required | Notes |
|---|---|---|
| `external_id` | yes | Your product id. How we match a later update to this product, so it must be stable for the product's life. |
| `name` | yes | |
| `price` | yes | A number (`389.5`), not a string. |
| `currency` | yes | ISO code. An unknown code is rejected, never guessed — guessing would reprice the shop into another country. |
| `url` | strongly recommended | **No url, no carousel.** |
| `images` | strongly recommended | **No image, no carousel.** `https` only, max 8 per item. We fetch each once and serve our own copy. |
| `image` | no | Single-image alternative to `images`. |
| `stock_qty` | no | Whole number, 0 to 2147483647. Answered as of the last push. |
| `in_stock` | no | Boolean. Derived from `stock_qty` when omitted. |
| `name_ar`, `description`, `description_ar` | no | |
| `compare_at_price`, `sku`, `category`, `tags`, `options` | no | |
| `specs` | no | Flat `{"RAM": "8GB"}` map. Goes to the assistant's knowledge, so it can answer "does it support eSIM" rather than only listing the product. Omit anything you do not know — do **not** send `"not specified"`, which becomes a retrievable non-answer. |
| `spec_text` | no | Free prose for anything that is not a key/value pair. |
| `sequence` | no | Any number that increases. Unix seconds (`1792008852`) or milliseconds (`1792008852038`) are both safe — stored as a 64-bit integer. Only ever skips an **update**; a full catalogue is always applied. |

### Shop-level fields

| Field | Notes |
|---|---|
| `shop.knowledge` | `[{question, answer}]` — delivery, returns, warranty policy. Max 200. Matching questions are replaced, not duplicated. |

### Request

```json
{
  "shop": {
    "knowledge": [
      { "question": "Do you deliver across Kuwait?",
        "answer": "Yes, within 24 hours. Orders over KWD 50 ship free." }
    ]
  },
  "items": [
    {
      "external_id": "SKU-1021",
      "name": "iPhone 16 Pro 256GB",
      "price": 389.5,
      "currency": "KWD",
      "stock_qty": 4,
      "url": "https://your-shop.com/p/iphone-16-pro",
      "images": ["https://your-shop.com/img/iphone16pro.jpg"],
      "specs": { "RAM": "8GB", "Storage": "256GB", "Warranty": "1 year" },
      "sequence": 1792008852
    }
  ]
}
```

### Response — `202`

```json
{
  "accepted": 412,
  "created": 18,
  "updated": 394,
  "deactivated": 3,
  "rejected": [{ "external_id": "SKU-9", "error": "unknown currency 'XY'" }],
  "warnings": { "SKU-77": ["no_image"] },
  "carousel_ready": 402,
  "images_pending": 18
}
```

`carousel_ready` here counts products **in this payload** carrying both a url
and an image — it describes your data, before we have fetched anything. The
`carousel_ready` in `GET /status` counts what we can actually show, which is
lower until the photos arrive.

Warning values: `no_image`, `no_url`, `too_many_images_truncated`.

One bad row never costs the rest of the catalogue.

### Limits

8 MB body, 2000 items, **one full catalogue every 5 minutes**.

---

## `PATCH /api/partners/v1/items`

Absolute price and stock for products we already hold. Send as often as you
like.

**Values are absolute, not adjustments.** `"stock_qty": 2` means two left in
total, not two fewer.

**Nothing is ever taken off sale here.** A product you omit is untouched; only
a full catalogue retires anything. A withdrawn product therefore stays on sale
until your next `PUT /catalog`, so send one on a regular cadence.

Fields: `external_id` (required), `price`, `currency`, `in_stock`,
`stock_qty`, `sequence`.

### Why `sequence` matters here

If your system retries and the retry arrives *after* a newer update, the old
price would overwrite the new one. We ignore anything older than what we hold
and report it as `stale_ignored`. A replayed absolute value is harmless; a
*late* one is not, and no idempotency key can tell those apart.

### Request

```json
{
  "items": [
    { "external_id": "SKU-1021", "stock_qty": 2, "sequence": 1792008852 },
    { "external_id": "SKU-1044", "price": 42.0, "in_stock": false, "sequence": 1792008852 }
  ]
}
```

### Response — `200`

```json
{
  "accepted": 1,
  "rejected": [],
  "warnings": { "SKU-1044": ["stale_ignored"] },
  "unknown": ["SKU-ZZZ"]
}
```

`unknown` lists ids we do not hold — not an error, but push the full catalogue
first if you did not expect it.

### Limits

1 MB body, 1000 items, 60 requests a minute.

---

## `GET /api/partners/v1/status`

How many products are live, how many can appear in a WhatsApp carousel, when
you last sent anything, and how the last push went. Use it to debug your own
integration without asking us.

### Response — `200`

```json
{
  "items_active": 412,
  "items_inactive": 3,
  "carousel_ready": 402,
  "last_full_push_at": "2026-01-14T15:17:02Z",
  "last_update_at": "2026-01-14T15:41:20Z",
  "last_run": {
    "kind": "snapshot",
    "at": "2026-01-14T15:17:02Z",
    "accepted": 412,
    "created": 18,
    "updated": 394,
    "deactivated": 3,
    "rejected": [{ "external_id": "SKU-9", "error": "unknown currency 'XY'" }],
    "warnings": { "SKU-77": ["no_image"] },
    "images_stored": 394,
    "images_skipped": 18,
    "image_errors": { "SKU-77": "HTTP 404" },
    "images_at": "2026-01-14T15:17:09Z"
  },
  "carts": {
    "recovery_enabled": true,
    "carts_held": 180,
    "carts_by_status": { "active": 40, "abandoned": 120, "recovered": 20 },
    "reminders_sent": 120,
    "carts_recovered": 20,
    "carts_converted_unprompted": 9,
    "recovered_amount": 8420.5
  }
}
```

**Images are fetched after the push answers.** `images_stored`,
`images_skipped` and `image_errors` therefore appear a few seconds later —
this is where to look when a product arrived but its photo did not.
`image_errors` is keyed by your own `external_id`, capped at 25 entries.
`images_at` says which push that image outcome belongs to: compare it against
your push time before trusting the counts.

### Limits

120 requests a minute.

---

## `POST /api/partners/v1/carts`

Optional. The current contents of a shopping cart, so the assistant can follow
up on baskets left behind.

Send the cart's current contents whenever they change — the contents you send
replace what we held, so a retry is harmless and there is no need to send
"added one of these".

**Upsert only.** A cart you leave out 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 on every call.

### Fields

| Field | Required | Notes |
|---|---|---|
| `external_id` | yes | Your cart id. |
| `recovery_url` | yes | `https`. The reminder **is** a link back to this cart. |
| `total_amount` | yes | Number. |
| `currency` | yes | ISO code. |
| `customer_phone` **or** `customer_email` | yes | Without one there is nobody to remind. |
| `items` | no | `[{external_id, name, quantity, price, variant}]`, max 100. Omitting them weakens the message considerably. |
| `customer_locale` | no | Two-letter code; sets the language of the reminder. |
| `discount_code` | no | **Your** code, applied by **your** checkout. See below. |

A cart missing `recovery_url`, or missing both contact fields, is **rejected
rather than stored** — those are exactly the carts that look healthy in
`/status` and can never send anything.

### Discounts

We never mint a discount code for a pushed shop. A code from our side exists
only in our records and your checkout would reject it, which is worse for the
shopper than no discount at all. Send your own in `discount_code` and it rides
along on the reminder.

### Request

```json
{
  "carts": [{
    "external_id": "CART-9911",
    "recovery_url": "https://your-shop.com/cart/9911",
    "customer_phone": "+96555512345",
    "total_amount": 429.5,
    "currency": "KWD",
    "items": [
      { "external_id": "SKU-1021", "name": "iPhone 16 Pro 256GB",
        "quantity": 1, "price": 429.5 }
    ]
  }]
}
```

### Response — `200`

```json
{
  "accepted": 1,
  "rejected": [],
  "warnings": {},
  "created": 1,
  "updated": 0,
  "recovery_enabled": true
}
```

**Check `recovery_enabled` before concluding your integration works.** The
merchant must switch cart follow-ups on. Until they do, this endpoint answers
normally, stores nothing, and returns `recovery_enabled: false` with a `note`
saying so.

Warning values: `no_items`, `no_phone_email_only`.

### Limits

2 MB body, 500 carts, 300 requests a minute.

---

## `POST /api/partners/v1/orders`

Optional, and worth sending even if you do not use carts.

Two jobs: it stops a shopper who has already bought from being chased about
the cart they bought from, and it makes the follow-ups measurable — the cart
records which reminder was the last one sent before the order arrived.

Send **every** order, not only ones you think came from a cart.

### Fields

| Field | Required | Notes |
|---|---|---|
| `external_id` | yes | Your order id. |
| `cart_external_id` | one of these three | The surest match. |
| `customer_phone` | one of these three | Falls back to matching the shopper. |
| `customer_email` | one of these three | |
| `order_number` | no | Human-facing number, if different from the id. |
| `total_amount`, `currency` | no | Used for recovered-revenue reporting. |
| `discount_code` | no | Helps attribution when a reminder carried a code. |

### Request

```json
{
  "orders": [{
    "external_id": "SO-55021",
    "cart_external_id": "CART-9911",
    "customer_phone": "+96555512345",
    "total_amount": 429.5,
    "currency": "KWD"
  }]
}
```

### Response — `200`

```json
{
  "accepted": 1,
  "rejected": [],
  "warnings": {},
  "carts_closed": 1,
  "no_cart_matched": ["SO-55022"]
}
```

`no_cart_matched` is information, not an error — most orders are not recovered
carts.

### Limits

2 MB body, 500 orders, 300 requests a minute.

---

## Status codes

| Code | Meaning |
|---|---|
| `200` | Applied. Check `rejected` and `warnings` in the body. |
| `202` | `PUT /catalog` only. Products saved; images follow. |
| `401` | Key unknown, wrong, malformed or switched off. Never says which. |
| `409` | A push is already running for this merchant, or the shop is not set up. |
| `413` | Body over the cap. |
| `422` | The payload could not be parsed at all. |
| `429` | Rate limited. Honour `Retry-After` rather than retrying in a loop. |

Rejections are **per row**, never per request: `{"accepted": n, "rejected":
[{"external_id", "error"}], "warnings": {...}}`. One bad row never costs the
rest of the payload.

## Freshness contract

We serve the last thing you sent and tell the customer when it was sent. We
never call your system to check. Push cadence is therefore yours to own — a
nightly full catalogue plus updates as prices and stock move is the shape that
works.

## Implementation notes for an agent

- Treat every payload as **absolute state**, never as an event. This is what
  makes retries safe.
- `PUT /catalog` is a full snapshot and will deactivate omissions. `PATCH
  /items` and `POST /carts` are upserts and never deactivate anything.
- Always read `rejected` and `warnings` from a 2xx. A 200 does not mean every
  row landed.
- `sequence` must increase per item. Unix seconds or milliseconds both work.
- Image URLs must be `https` and reachable without a login. Private,
  link-local and internal addresses are refused.
- A product needs both a url and an image to appear as a tappable card.
- Do not send `"not specified"` as a spec value.
