# Refresh API

Connect your own software — a website, an online shop, 1C, a spreadsheet, a BI
tool or an AI assistant — to your Refresh shop. Read products, stock,
customers, sales and every report (JSON or Excel); record sales, receive goods,
move stock, top up customers' balances; and get told about changes as they happen.

- **Base URL:** `https://api.refresh.tj/public/v1`
- **Interactive reference** (every endpoint, field and code): [/public/v1/reference](/public/v1/reference)
- **OpenAPI 3:** [/public/v1/openapi.json](/public/v1/openapi.json) · **For AI agents:** [/public/v1/llms.txt](/public/v1/llms.txt)

## Quick start

1. In Refresh open **Settings → API keys → Create key**. Name it, tick what it
   may read and change, choose branches, create. Copy the key — it is shown once.
2. Call the API with the key:

```bash
curl https://api.refresh.tj/public/v1/company \
  -H "Authorization: Bearer rk_your_key"
```

The answer names your shop and what this key may do (its scopes and branches).

## Keys and what they can see

A key belongs to one shop. It reads only the **data groups (scopes)** and
**branches** ticked when it was made, and **never more than the person who made
it can see in Refresh** — if that person loses a permission or a branch, the key
loses it too; if they leave the shop, the key stops (`403 key_inactive`).
Revoke a key in Settings at any time; it stops at once.

Send it as `Authorization: Bearer rk_…` (or `X-Api-Key: rk_…`). Keep it on your
server — never in a browser page or a mobile app.

| Scope | Lets the key |
|---|---|
| `catalog.read` | read products, variants, barcodes, prices, categories |
| `inventory.read` | read stock per branch, transfers, write-offs |
| `costs.read` | see supply prices, cost of goods and profit (hidden otherwise) |
| `customers.read` | read customers |
| `sales.read` | read sales with lines, payments and returns |
| `reports.read` | read every report and download it as Excel (each also needs that report's own permission in Refresh) |
| `stockcounts.read` | read stock counts and their results |
| `purchasing.read` | read suppliers and purchase orders |
| `gift_cards.read` | check a gift card's balance |
| `sales.write` | record sales and refunds |
| `customers.write` | create and edit customers, issue loyalty cards, top up and pay out their balance |
| `catalog.write` | create and edit products |
| `transfers.write` | create, send, receive and cancel transfers |
| `writeoffs.write` | request, approve and reject write-offs |
| `purchasing.write` | create, send and receive purchase orders |
| `gift_cards.write` | sell, top up and void gift cards |
| `loyalty.write` | adjust customers' loyalty cashback |

API access is part of the Pro plan.

## Conventions

- **Lists** answer `{"data": [...], "next_cursor": "...", "has_more": true}`.
  Pass `next_cursor` back as `cursor` for the next page; `limit` is 1–500
  (default 100). Pages are ordered by id, so nothing is skipped or repeated
  while new records arrive.
- **Incremental sync:** most lists take `updated_since` (ISO 8601, e.g.
  `2026-11-15T09:30:00Z`) and return only what changed since. Store the time
  you last synced and pass it next time. Stock has no `updated_since` — poll it,
  or use the `stock.updated` webhook.
- **Times** are UTC with a trailing `Z`. **Dates** (`date_from`, `date_to`) are
  the shop's own calendar days in its timezone, inclusive.
- **Money** is a number with 2 decimals in the shop's currency (usually TJS);
  **quantities** have 3 decimals (weighed goods).
- **Products:** an ordinary product has its SKU, barcode and price on the
  product and `variants: []`; a product with variants carries them per variant.
- **Rate limit:** 300 requests per minute per key. Over it you get `429` with
  `Retry-After`.

## Errors

Every error is `{"error": {"code": "...", "message": "..."}}`. Branch on `code`;
`message` is for people and may change.

| HTTP | code | Means |
|---|---|---|
| 400 | `invalid_request` | a parameter is missing or wrong (`fields` says which) |
| 401 | `unauthorized` | no key, a wrong one, revoked or expired |
| 402 | `subscription_inactive` | the shop's subscription is not active |
| 403 | `plan_upgrade_required` | the shop's plan does not include this |
| 403 | `insufficient_scope` / `forbidden` / `key_inactive` | the key, or its creator, may not do this |
| 404 | `not_found` | no such record — or not one this key can see |
| 409 | `conflict` and specific codes | the record is in a state that does not allow it |
| 422 | specific codes | e.g. `unknown_product`, `account_not_allowed`, `insufficient_…_stock` |
| 429 | `rate_limited` | slow down, retry after `Retry-After` seconds |

## Writing data

Every write (POST, PATCH) needs an **`Idempotency-Key`** header — a unique value
per operation, such as a UUID. If the network fails, send the same request
with the same key again: you get the first answer back
(`Idempotent-Replayed: true`) and nothing happens twice. The same key with a
different body is refused (`idempotency_key_reused`). Keys are kept 7 days.

Writes run the same code as the Refresh POS and are recorded under the key's
creator, so the shop's own rules apply: stock follows the shop's
«negative stock» setting, discounts follow its cashier policy, and so on.

### Record a sale — one call

```bash
curl -X POST https://api.refresh.tj/public/v1/sales \
  -H "Authorization: Bearer rk_your_key" \
  -H "Idempotency-Key: 6f1c2e8a-6c55-4b7b-9a2e-1f0d6c1b1a01" \
  -H "Content-Type: application/json" \
  -d '{
    "branch_id": 5,
    "lines": [{"sku": "MD-1001", "quantity": 2}, {"barcode": "4600000999991", "quantity": 1}],
    "payments": [{"account_id": 7}],
    "external_id": "WEB-1001"
  }'
```

- A line names its product by `product_id`, `variant_id`, `sku` or `barcode`.
- One payment **without** `amount` = paid in full into that account. Payments
  **with** amounts need a `customer_id`; what they leave unpaid becomes the
  customer's debt (send `debt_due_date`).
- Money lands only in **non-cash** accounts (card, bank, wallet…) of the sale's
  branch — see `GET /payment-methods`. Cash belongs to a cashier's shift.
- **Gift card:** add `"gift_card": {"code": "…"}` (optionally with `amount`). The
  card pays first — `amount`, or as much as it holds — and `payments` cover the
  rest by the rules above; a card alone may pay the whole sale. If it falls short
  and there is no customer to owe the rest, the sale is refused
  (`payment_short`) and the card keeps its balance.
- The sale shows in Refresh as «Via API».

Refund it: `POST /sales/{id}/refunds` — everything, or `{"lines": [{"line_id": …, "quantity": …}]}`.
The money goes back to the account the sale was paid into; a gift card's share
goes back onto the card.

### Customers

`POST /customers` with `first_name` and `phone` creates a customer — or, if one
already has that phone, returns them with `"created": false` instead of making a
duplicate. `PATCH /customers/{id}` changes only the fields you send.

**Balance and cashback.** `GET /customers/{id}/balance` shows the customer's
balance, cashback and the balance history.
`POST /customers/{id}/balance/deposits` adds to the balance — `"kind": "top_up"`
with the non-cash `account_id` the money came into, or `"kind": "gift"` for a
bonus with no money. `POST /customers/{id}/balance/withdrawals` pays part of it
back out of an account. `POST /customers/{id}/cashback/adjustments` with a signed
`amount` and a `reason` adds or takes cashback (`loyalty.write`); it never goes
below 0 and each change is in the shop's audit log.

### Stock in, stock between branches, stock out

- **Receive goods:** `POST /purchase-orders` with `supplier_id`, `branch_id`,
  `lines` (product + `quantity` + `unit_cost`) and `"receive": true` — the goods
  are in stock at once, at that cost, and the supplier's debt is recorded.
  Without `receive` the order stays a draft (`"issue": true` sends it); receive
  later with `POST /purchase-orders/{id}/receive`.
- **Move between branches:** `POST /transfers` with `from_branch_id`,
  `to_branch_id` and `lines` creates and sends a transfer;
  `POST /transfers/{id}/receive` takes it in (name the short lines, or send `{}`
  if everything arrived).
- **Write off:** `POST /write-offs` with `branch_id`, `reason` and `lines`;
  `"approve": true` approves at once.

A call with several lines is all or nothing: if a line fails, what was created
is undone.

### Products and gift cards

`POST /products` creates an ordinary product (`name`, `price`, optional `sku`,
`barcode`, `supply_price`, `category_ids`…); `PATCH /products/{id}` changes it,
`{"is_archived": true}` archives it. Stock is never set directly — it comes in
by a receipt or a transfer.

A product with variants is created in Refresh; through the API,
`PATCH /variants/{id}` changes one variant's `sku`, `barcode`, `price`,
`wholesale_price`, `supply_price` or `is_archived`.

**Custom fields** — the shop's own product fields («Характеристики»: material,
country, brand…) — are listed by `GET /custom-fields` and come with every
product as `custom_fields`. Set them on create or change with
`"custom_fields": [{"id": 12, "value": "Cotton"}]`: only the fields named are
touched, `null` clears one, and a list field takes its choice's id or exact text.

`POST /gift-cards` sells a card (`code`, `amount`, `branch_id`, non-cash
`account_id`); `GET /gift-cards/{code}` checks its balance; `/reload` tops it up,
`/void` cancels it; `POST /gift-cards/batches` creates unsold cards to print.

## Reports

`GET /reports` lists every report and whether this key may run it. Each report
is the same query as its screen in Refresh — the same figures for the same
branches and days:

`store`, `payments`, `products`, `sales-by-category`, `sales-by-supplier`,
`stock`, `receipts`, `transfers`, `write-offs`, `returns`, `stock-counts`,
`product-performance`, `abc`, `unsold-products`, `sellers`, `sales-by-seller`,
`customers`, `customer-purchases`, `receivables`, `top-debtors`,
`overdue-debts`, `cart-audit`, `profit-and-loss`.

```bash
curl "https://api.refresh.tj/public/v1/reports/products?date_from=2026-11-01&date_to=2026-11-15" \
  -H "Authorization: Bearer rk_your_key"
```

Pages of up to 200 rows (`cursor`, `limit`); filters such as `branch_id`,
`category_id`, `search` where the screen has them. Add `/export` for the whole
report as an Excel file (`GET /reports/abc/export?...`, up to 10 000 rows; the
key's creator needs «Download reports»). Without `costs.read` the cost, profit
and margin columns are left out of both, and `profit-and-loss` is refused.
`GET /reports/sales-summary` and `/reports/sales-by-product` remain as compact
versions.

**Stock counts:** `GET /stock-counts` and `GET /stock-counts/{id}` — each count
with its lines: expected, counted and the difference (`stockcounts.read`).

## Webhooks

Refresh can POST an event to your server when something changes — whether it
happened at a till, in the POS or through this API. Add one in
**Settings → API keys → Webhooks**: an `https://` address and the events you
want. Use «Send a test event» to check your server.

| Event | When |
|---|---|
| `sale.created`, `sale.updated` | a sale is recorded; it changes (refund, payment) |
| `customer.created`, `customer.updated`, `customer.deleted` | customer records change |
| `product.created`, `product.updated` | a product or one of its variants changes |
| `stock.updated` | stock of a product at a branch changes |

The body:

```json
{
  "event_id": "evt_afef8ae2fe2c4f688a841ec7a126eb95",
  "type": "sale.created",
  "created_at": "2026-11-15T09:28:08Z",
  "shop_id": 3,
  "data": {"type": "sale", "id": 923, "object": { "...": "the sale, as GET /sales/923 returns it" }}
}
```

**Answer with any 2xx within 10 seconds.** Otherwise Refresh tries again after
1, 2, 4, 8, 16, 32 and 60 minutes, then 2, 4, 8 and 8 hours — for 24 hours.
Events can arrive more than once and in any order: skip an `event_id` you have
already handled. A webhook only receives what its creator can see; supply
prices and profit are never sent.

**Check the signature** on every request. `X-Refresh-Signature` is the Base64
HMAC-SHA256 of your webhook URL followed by the raw request body, keyed with the
webhook's signing secret (Settings → Webhooks → «Signing secret»):

```python
import base64, hashlib, hmac

def is_from_refresh(secret: str, url: str, raw_body: bytes, signature: str) -> bool:
    expected = base64.b64encode(hmac.new(secret.encode(), url.encode() + raw_body, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, signature)
```

```javascript
const crypto = require("crypto");
function isFromRefresh(secret, url, rawBody, signature) {
  const expected = crypto.createHmac("sha256", secret).update(url).update(rawBody).digest("base64");
  return expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

```php
function is_from_refresh($secret, $url, $rawBody, $signature) {
  $expected = base64_encode(hash_hmac('sha256', $url . $rawBody, $secret, true));
  return hash_equals($expected, $signature);
}
```

Use the URL exactly as saved in Refresh and the body exactly as received.
Other headers: `X-Refresh-Event-Id`, `X-Refresh-Event-Type`,
`X-Refresh-Retry-Number`, `X-Refresh-Retry-Reason`.

## AI assistants (MCP)

Refresh is also a read-only **MCP server**, so an AI assistant can answer
questions about your shop from live data: «what sold best last week?», «who owes
us the most?», «which products are running out?».

**claude.ai or ChatGPT (in the browser):** add a custom connector with the
address `https://api.refresh.tj/public/v1/mcp`. The app sends you to Refresh to
sign in; choose what it may read and which branches, and allow. It reads only,
never more than you can, and appears in **Settings → API keys**, where you can
disconnect it at any time. (OAuth 2.1 with PKCE and dynamic client
registration; discovery at `/.well-known/oauth-protected-resource/public/v1/mcp`.
A connection that goes unused for 30 days lapses.)

**Developer tools** take an API key instead — give it only the read scopes you
want the assistant to have.

**Claude Code:**

```bash
claude mcp add --transport http refresh https://api.refresh.tj/public/v1/mcp \
  --header "Authorization: Bearer rk_your_key"
```

**Cursor** (`.cursor/mcp.json`) or any client that takes a URL and headers:

```json
{"mcpServers": {"refresh": {"url": "https://api.refresh.tj/public/v1/mcp",
  "headers": {"Authorization": "Bearer rk_your_key"}}}}
```

**Claude Desktop** (`claude_desktop_config.json`, through the `mcp-remote` bridge):

```json
{"mcpServers": {"refresh": {"command": "npx", "args": ["mcp-remote",
  "https://api.refresh.tj/public/v1/mcp", "--header", "Authorization: Bearer rk_your_key"]}}}
```

Tools: `shop_overview`, `sales_summary`, `query` (group and filter sales, sale
lines, stock, debts and customers), `top_sellers`, `list_sales`, `get_sale`,
`find_products`, `find_customers`. Dates and hours are the shop's own.

## Recipes

**Keep an online shop's stock in step.** Subscribe a webhook to
`stock.updated`, and once a night page through `GET /stock?branch_id=…` to
correct anything missed.

**Send website orders to Refresh.** On checkout, `POST /sales` with the order's
lines, its own number as `external_id`, and the card or wallet account it was
paid into. Use the order number as the `Idempotency-Key` so a retried request
never books it twice.

**Daily report into a spreadsheet.** Each morning
`GET /reports/sales-summary?date_from=YESTERDAY&date_to=YESTERDAY` — the same
figures as Refresh's own shop report.

## Changes

The API is versioned in the path. Within `/public/v1` fields and endpoints are
only ever added, never removed or changed in meaning; a breaking change ships as
`/public/v2`. Ignore fields you do not know.
