# bluemint Shop API

Connect a shop's own website to bluemint: import products, correct stock, create and update orders, and receive signed webhooks.

- Base URL: `https://bluemint.app/api/v1`
- JSON over HTTPS. Auth on every request: `Authorization: Bearer <secret key>`
- Keys are created by the shop owner in the dashboard: Settings → Website API. Shown once. Server-side only, never in browser code.
- `sk_test_…` = sandbox shop (safe to experiment). `sk_live_…` = the real shop. Build against a test key first.
- Read-only keys get `403 insufficient_scope` on writes. Missing/wrong/revoked key → `401 invalid_key`. Shop API not switched on → `403 not_enabled`.
- Rate limit ≈120 requests/minute per key → `429 rate_limited` with `Retry-After` (seconds).
- Money: responses use strings in taka with 2 decimals (`"1250.00"`); requests use JSON numbers (`1250`, `1250.50`, max 2 decimals). Timestamps: ISO 8601 UTC.
- Stock model: bluemint owns the stock count. Every sale (website, counter, chat) lowers it. Read `available` from products or the `stock.changed` webhook. `PUT /stock` is only for corrections.

```http
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

## Products

### PUT /products — create or update (batch ≤ 100)
Body `{ "products": [...] }` (or a bare array). Matched by your `external_id`; re-sending updates. Fields: `external_id` (required), `sku`, `name` (required), `description`, `price` (required), `images` (URLs, downloaded in background; ignored for test keys), `category`, `weight_grams`, `barcode`, `active`, `variants[]` (`external_id`, `sku` required; `attributes` object; optional `price` override). A hand-made product with the same `sku` is adopted, not duplicated; a sku owned by another sync returns an item error.

```bash
curl -X PUT https://bluemint.app/api/v1/products \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "products": [{
      "external_id": "wp-1042",
      "sku": "TEE-BLK",
      "name": "Black tee",
      "description": "Cotton, regular fit",
      "price": 650,
      "images": ["https://example.com/tee.jpg"],
      "category": "T-shirts",
      "weight_grams": 220,
      "barcode": "8901234567890",
      "active": true,
      "variants": [
        { "external_id": "wp-1042-m", "sku": "TEE-BLK-M", "attributes": { "size": "M" } },
        { "external_id": "wp-1042-xl", "sku": "TEE-BLK-XL", "attributes": { "size": "XL" }, "price": 700 }
      ]
    }]
  }'
```

Response — per item `created` | `updated` | `error`; one failure doesn't stop the rest:

```json
{ "data": [
  { "external_id": "wp-1042", "id": "cmk1…", "status": "created" },
  { "external_id": "bad-one", "status": "error", "error": "sku TEE-RED belongs to a product managed by another sync." }
] }
```

### GET /products — list with live stock
Query: `limit` 1–100 (default 50), `cursor` (from `next_cursor`; repeat until null), `updated_since` (ISO 8601; also returns inactive products with `active: false`). Errors: `400 invalid_limit | invalid_cursor | invalid_updated_since`.

```bash
curl "https://bluemint.app/api/v1/products?limit=50&updated_since=2026-10-01T00:00:00Z" \
  -H "Authorization: Bearer $KEY"
```

```json
{
  "data": [{
    "id": "cmk1…", "external_id": "wp-1042", "sku": "TEE-BLK", "name": "Black tee",
    "description": "Cotton, regular fit", "price": "650.00", "images": ["https://…"],
    "category": "T-shirts", "weight_grams": 220, "barcode": "8901234567890", "active": true,
    "updated_at": "2026-10-26T08:15:00.000Z",
    "variants": [{ "id": "cmv1…", "external_id": "wp-1042-m", "sku": "TEE-BLK-M",
                   "attributes": { "size": "M" }, "price": null, "available": 12 }],
    "available": 12
  }],
  "next_cursor": "MjAyNi0x…"
}
```

`available` = sellable now (on hand − reserved), `null` if untracked. Variant `price: null` = sells at product price.

### GET /products/{id}
By bluemint id, or your id with prefix: `/products/ext:wp-1042`. `404 not_found` if missing.

## Stock

### PUT /stock — correct counts (batch ≤ 500)
Body `{ "items": [...] }` (or a bare array). Each item: one of `sku` | `variant_external_id` | `product_external_id`, plus `quantity` (absolute on-hand, integer 0–1,000,000), optional `warehouse_id` (default warehouse otherwise). Products with variants must be addressed by variant.

```bash
curl -X PUT https://bluemint.app/api/v1/stock \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "items": [
    { "sku": "TEE-BLK-M", "quantity": 40 },
    { "variant_external_id": "wp-1042-xl", "quantity": 15 }
  ] }'
```

```json
{ "data": [
  { "ref": "TEE-BLK-M", "status": "set", "on_hand": 40 },
  { "ref": "wp-1042-xl", "status": "unchanged", "on_hand": 15 },
  { "ref": "NOPE", "status": "error", "error": "not_found" }
] }
```

Item status: `set` | `unchanged` | `error` (`not_found`, `variant_required`, `invalid_warehouse`, `no_warehouse`, `internal_error`).

## Orders

Idempotent by your `external_id`: re-sending returns the existing order with `200` instead of `201`. After a timeout or 5xx, retry the same request.
There is no customers endpoint: the customer travels inside `POST /orders` and is matched by `customer.phone`. There is no `Idempotency-Key` header: `external_id` makes retries safe.

### POST /orders
Items by `sku` | `variant_external_id` | `product_external_id`. Priced from the catalogue; `unit_price` honoured only if the shop allowed site prices on the key. `customer.phone` must be a Bangladesh mobile. `payment.method`: `COD` | `PAID` (with optional `amount_paid` for a part payment; rest collected on delivery). Optional `delivery_charge`, `note`, `customer.email`, `address.area`, `address.district`.

```bash
curl -X POST https://bluemint.app/api/v1/orders \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "external_id": "wp-order-8841",
    "customer": { "name": "Rahim Uddin", "phone": "01712345678", "email": "rahim@example.com" },
    "address": { "text": "House 12, Road 4, Dhanmondi", "area": "Dhanmondi", "district": "Dhaka" },
    "items": [
      { "sku": "TEE-BLK-M", "quantity": 2 },
      { "product_external_id": "wp-2001", "quantity": 1 }
    ],
    "delivery_charge": 80,
    "payment": { "method": "COD" },
    "note": "Call before delivery"
  }'
```

```json
{
  "id": "cmo1…", "number": "ORD-10231", "external_id": "wp-order-8841",
  "status": "received", "fulfillment_status": "none", "payment_status": "pending",
  "total": "1830.00", "delivery_charge": "80.00",
  "customer": { "name": "Rahim Uddin", "phone": "01712345678" },
  "address": "House 12, Road 4, Dhanmondi, Dhanmondi, Dhaka",
  "courier": null,
  "items": [
    { "sku": "TEE-BLK-M", "name": "Black tee", "quantity": 2, "unit_price": "650.00" },
    { "sku": "CAP-NVY", "name": "Navy cap", "quantity": 1, "unit_price": "450.00" }
  ],
  "created_at": "2026-10-26T08:20:00.000Z", "updated_at": "2026-10-26T08:20:00.000Z"
}
```

Responses: `201` created · `200` already existed · `400 invalid_request` · `422 invalid_phone | unknown_item | amount_too_large` · `500 internal_error` (safe to retry). Statuses are lowercase. `courier` becomes `{ "name": "pathao", "tracking_code": "…" }` once handed over.

### GET /orders/{id}
By bluemint id or `/orders/ext:<your id>`. Works for every order in the shop, from any channel.

### GET /orders?external_id=…
Returns `{ "data": [order] }` or an empty list.

### PATCH /orders/{id}
Only orders created through the API (else `409 not_api_order`). Optional fields: `status: "cancelled"` (other fields in the same request are then ignored), `address` {`text`, `area`, `district`}, `phone` (bad → `422 invalid_phone`), `note` (appended), `paid: true` (fully paid in advance). Once with a courier: `409 order_locked`. Cancelled/refunded/uncollected orders can't be marked paid: `409 invalid_transition`.

```bash
curl -X PATCH https://bluemint.app/api/v1/orders/ext:wp-order-8841 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "paid": true, "note": "Paid by card on site" }'
```

## Webhooks

The shop adds an HTTPS URL in Settings → Website API (live or test; a test URL only receives sandbox events). Events: `order.created`, `order.updated`, `stock.changed` (product with fresh `available`), `product.updated`.

```json
{
  "id": "evt_…",
  "type": "order.updated",
  "created_at": "2026-10-26T08:30:00.000Z",
  "business_id": "cmb1…",
  "mode": "live",
  "data": { /* the order or product, same shape as the GET calls */ }
}
```

`data` is the current state at send time (several quick changes may arrive as one). `mode`: `live` | `test`.

Headers: `X-ISR-Event` (type), `X-ISR-Delivery` (delivery id), `X-ISR-Timestamp` (unix seconds), `X-ISR-Signature` = `sha256=` + hex HMAC-SHA256 of `{timestamp}.{raw body}` with the endpoint's signing secret. Verify on the raw body before parsing; reject timestamps older than 5 minutes.

```js
const crypto = require("crypto");
function verify(secret, timestamp, rawBody, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(String(header ?? ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b) && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
}
```

```php
function verify($secret, $timestamp, $rawBody, $header) {
  $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
  return hash_equals($expected, $header) && abs(time() - (int)$timestamp) < 300;
}
```

Delivery rules:
- Reply 2xx within 10 s; anything else, a timeout or a redirect is a failure.
- Retries after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, then stop (9 tries).
- The same event can arrive twice: process each `id` once.
- Order isn't guaranteed: compare `data.updated_at` and ignore older state.
- Changes made by a key are not sent back to the URL linked to that key (no loops).
- 50 consecutive fully-failed deliveries disable the URL until re-enabled in Settings.
- While the Shop API is switched off, webhooks stop and are not sent later.

## Sandbox (test keys)
- Separate copy of the shop: up to 500 products copied, 100 in stock each, no images, no `external_id` (address copies by `sku`).
- No real courier booking. Settings → Website API can move a test order to shipped / delivered / returned and reset the sandbox.
- Reset replaces products and stock; test customers and accounting entries accumulate.

## Errors
Always `{ "error": { "code", "message" } }`:

```json
{ "error": { "code": "invalid_request", "message": "items.0.quantity: Expected number" } }
```

| HTTP | code | meaning |
|---|---|---|
| 400 | invalid_json | body isn't JSON |
| 400 | invalid_request | field missing/wrong (message names it) |
| 400 | invalid_limit / invalid_cursor / invalid_updated_since | bad query value |
| 401 | invalid_key | missing, wrong or revoked key |
| 403 | insufficient_scope | read-only key tried to write |
| 403 | not_enabled | Shop API not switched on for the shop |
| 404 | not_found | no such product/order |
| 409 | not_api_order / order_locked / invalid_transition | order can't be changed that way |
| 422 | invalid_phone / unknown_item / amount_too_large | order understood but can't be placed |
| 429 | rate_limited | wait for Retry-After |
| 500 | internal_error | retry; re-sending an order with the same external_id is safe |

Batch calls (`PUT /products`, `PUT /stock`) answer 200 and report each item separately.
