# ViperFlow REST API v1

The ViperFlow public API lets your own systems — an online store, an ERP, a BI pipeline, an
automation platform — read and write ViperFlow data for **one ViperFlow account (tenant)**
over HTTPS with a secret API key.

- Machine-readable spec: [`openapi.yaml`](openapi.yaml) (OpenAPI 3.1)
- Webhooks (events pushed to your server, inbound connections): [`WEBHOOKS.md`](WEBHOOKS.md)
- API keys, webhook endpoints, delivery history and the request log live in the ViperFlow app
  under **הגדרות › מפתחים** (Settings › Developers).

## Contents

1. [Principles](#1-principles)
2. [Base URL and region](#2-base-url-and-region)
3. [Authentication and API keys](#3-authentication-and-api-keys)
4. [Environments and test mode](#4-environments-and-test-mode)
5. [Scopes](#5-scopes)
6. [Requests](#6-requests)
7. [Responses](#7-responses)
8. [Errors](#8-errors)
9. [Rate limits](#9-rate-limits)
10. [Pagination, sorting and filtering](#10-pagination-sorting-and-filtering)
11. [Incremental sync recipe](#11-incremental-sync-recipe)
12. [Idempotency](#12-idempotency)
13. [Dates, money and units](#13-dates-money-and-units)
14. [External references](#14-external-references)
15. [Endpoint reference](#15-endpoint-reference)
16. [Examples](#16-examples)
17. [Versioning](#17-versioning)
18. [Security notes](#18-security-notes)
19. [Known limitations](#19-known-limitations)

---

## 1. Principles

- **Server to server only.** API keys are secrets. The API sends no CORS headers (except for
  `GET /v1/health`), so browsers refuse to call it — never put a key in a web page, a mobile app
  or a desktop app you distribute.
- **The key decides the account.** Every key belongs to exactly one ViperFlow account. No
  request parameter selects or switches the account; ids of another account's records behave
  exactly like ids that do not exist (`404 not_found`).
- **Same rules as the app.** Each key acts as a dedicated, non-human "integration user" whose
  permissions are exactly the scopes you granted the key. Every database rule the web app is
  subject to — row-level security, permission checks, pricing and discount approval guards,
  status transitions, document immutability — applies to API calls too. The API cannot do
  anything a ViperFlow user holding the same permissions could not do.
- **Business operations, not raw tables.** Writes are explicit operations (create an order,
  confirm it, record a payment). Unknown body fields are rejected; there is no mass assignment.
- **Every change is an event.** Changes made through the API, the web app, the desktop app or
  automations all emit the same webhook events (see [`WEBHOOKS.md`](WEBHOOKS.md)).

## 2. Base URL and region

```
https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/api
```

All paths in this document are relative to that base and start with `/v1/`, for example:

```
GET https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/api/v1/orders
```

The Developers screen shows the exact base URL for your account with a copy button (it prints
it with the version segment already appended, `…/functions/v1/api/v1`, so you can paste it and
add `/orders`). The `/functions/v1/api` part is a hosting detail: if ViperFlow moves the API to
a dedicated domain, the `/v1/...` paths stay identical (`https://<api-domain>/v1/orders`).

**Region (recommended).** The API runs next to the database in `ap-northeast-1`. Send the
header `x-region: ap-northeast-1` on every request so it is served in that region instead of
the region closest to your server (which adds a cross-region database round trip per request).
If you cannot set headers, the query parameter `?forceFunctionRegion=ap-northeast-1` does the
same; it is accepted on every endpoint and never counts as an unknown parameter.

## 3. Authentication and API keys

Send the key as a bearer token:

```
Authorization: Bearer vf_live_<40 letters and digits>
```

| Key format | Environment |
|---|---|
| `vf_live_` + 40 base62 characters | live |
| `vf_test_` + 40 base62 characters | test (see [§4](#4-environments-and-test-mode)) |

**Creating a key.** In the app: **הגדרות › מפתחים › מפתחות API › יצירת מפתח**. You need the
`integrations.manage` permission (account owners have it). You choose:

- a name (shown in logs and as the author of the key's changes, e.g. `API · WooCommerce sync`),
- the environment (`live` or `test`),
- the scopes ([§5](#5-scopes)) — a non-owner can only grant scopes whose permissions they hold
  themselves, and scopes of modules your plan does not include cannot be granted,
- an optional expiry date,
- the per-key rate limit (10–1000 requests/minute, default 120).

The full key is displayed **once**. ViperFlow stores only its SHA-256 hash and a display prefix
(`vf_live_Ab3dE9xQ`); nobody — including ViperFlow support — can show it again. Lost keys are
replaced, not recovered. An account can have up to 50 active keys.

**Rotating a key.** Create a new key with the same scopes, deploy it, confirm in the request log
that the old key is no longer used, then revoke the old key. Revocation is immediate.

**Scopes are fixed.** A key's scopes cannot be edited after creation; create a new key instead.

Authentication failures:

| Status | Code | When |
|---|---|---|
| 401 | `missing_api_key` | No `Authorization` header |
| 401 | `invalid_api_key` | Not a `Bearer vf_live_…`/`vf_test_…` key, or unknown key |
| 401 | `api_key_revoked` | The key was revoked (or its integration user was deactivated) |
| 401 | `api_key_expired` | The key's expiry date passed |
| 429 | `rate_limited` | Too many failed authentication attempts from your IP address ([§9](#9-rate-limits)) |

`GET /v1/me` returns the identity of the key you are using — the fastest way to check a key.

## 4. Environments and test mode

| | `vf_live_` keys | `vf_test_` keys |
|---|---|---|
| Reads (`GET`) | live account data | **the same live account data** |
| Writes (`POST`/`PATCH`/`PUT`/`DELETE`) | committed | **validated and executed, then always rolled back** |
| Webhook events from writes | yes | no |
| `Idempotency-Key` | honoured | ignored (nothing is stored) |
| Rate limits, request log | yes | yes (log rows marked as dry run) |

> **There is no sandbox dataset.** A test key reads your real customers, orders and prices. Its
> writes run the full validation and business logic — including every database guard — inside a
> transaction that is always rolled back, so nothing is saved, no webhook is sent and no
> notification goes out. Use test keys to develop and verify request bodies safely against real
> data; give them read scopes only as broad as you are comfortable exposing.

A test-mode write returns the response the live call would have returned, plus a top-level
`"livemode": false` and the header `X-ViperFlow-Dry-Run: true`. Ids, order numbers and customer
numbers in that response are **provisional**: the records do not exist afterwards, and the same
numbers may later be given to real records. `GET /v1/me` reports `"livemode": false` for test
keys. Every response of an authenticated request carries `X-ViperFlow-Environment: live|test`.

## 5. Scopes

A key holds one or more scopes. Each scope grants the integration user a fixed set of ViperFlow
permission keys; each endpoint requires one scope.

| Scope | Hebrew label (in the app) | Grants permission keys | Endpoints |
|---|---|---|---|
| `customers:read` | קריאת לקוחות | `customers.view`, `field.customers.financials` | `GET /v1/customers`<br>`GET /v1/customers/{id}` |
| `customers:write` | יצירה ועדכון לקוחות | `customers.view`, `customers.create`, `customers.edit`, `customers.contacts.manage` | `POST /v1/customers`<br>`PATCH /v1/customers/{id}`<br>`POST /v1/customers/{id}/contacts`<br>`PATCH /v1/customers/{id}/contacts/{contact_id}` |
| `orders:read` | קריאת הזמנות | `orders.view`, `field.orders.financials`, `field.orders.payment_details` | `GET /v1/orders`<br>`GET /v1/orders/{id}` |
| `orders:write` | יצירה ועדכון הזמנות | `orders.view`, `orders.create`, `orders.edit`, `orders.fulfil`, `orders.change_status`, `orders.cancel`, `payments.schedule.manage` | `POST /v1/orders`<br>`PATCH /v1/orders/{id}`<br>`POST /v1/orders/{id}/confirm`<br>`POST /v1/orders/{id}/cancel` |
| `products:read` | קריאת קטלוג מוצרים | `products.view`, `field.products.pricing` | `GET /v1/products`<br>`GET /v1/products/{id}`<br>`GET /v1/categories` |
| `inventory:read` | קריאת מלאי | `inventory.view`, `inventory.history.view`, `maintenance.view` | `GET /v1/inventory`<br>`GET /v1/inventory/movements` |
| `payments:read` | קריאת תשלומים | `payments.view` | `GET /v1/payments`<br>`GET /v1/payments/{id}` |
| `payments:write` | רישום תשלומים | `payments.view`, `payments.create` | `POST /v1/payments` |
| `documents:read` | קריאת מסמכים וחשבוניות | `documents.view` | `GET /v1/documents`<br>`GET /v1/documents/{id}` |
| `suppliers:read` | קריאת ספקים | `procurement.view` | `GET /v1/suppliers`<br>`GET /v1/suppliers/{id}` |
| `purchase_orders:read` | קריאת הזמנות רכש | `procurement.view`, `field.procurement.costs` | `GET /v1/purchase-orders`<br>`GET /v1/purchase-orders/{id}` |
| `warehouse:read` | קריאת משימות מחסן | `warehouse.view` | `GET /v1/warehouse/tasks`<br>`GET /v1/warehouse/tasks/{id}` |
| `external_refs:write` | ניהול מזהים חיצוניים | `integrations.external_refs.manage` | `PUT /v1/external-refs`<br>`DELETE /v1/external-refs/{id}` |

Notes:

- `GET /v1/me` and `GET /v1/health` need no scope. `GET /v1/external-refs` requires the **read**
  scope of the `entity_type` you query (e.g. `orders:read` for `entity_type=order`).
- A write scope does not include its list endpoints: `payments:write` records payments but a key
  also needs `payments:read` to call `GET /v1/payments`.
- `orders:write` deliberately **does not** include `orders.pricing.edit` or
  `orders.discount.approve_high`: the database applies the price-edit and discount-threshold
  rules to API writes exactly as it does to a sales user without those permissions
  ([§15.4](#post-v1orders)).
- **Plans.** Permissions of modules your plan does not include are removed from the integration
  user at request time. A route whose scope depends on such a module returns
  `403 module_not_enabled` (details: `required_scope`). One exception: `orders:write` keeps
  working when the payments module is not in the plan — order creation then simply skips the
  payment schedule, exactly like the web app.
- `403 insufficient_scope` (details: `required_scope`) means the key lacks the route's scope.

## 6. Requests

**Headers you send**

| Header | Required | Notes |
|---|---|---|
| `Authorization: Bearer <key>` | yes (except `/v1/health`) | [§3](#3-authentication-and-api-keys) |
| `Content-Type: application/json` | on requests with a body | otherwise `415 unsupported_media_type` |
| `x-region: ap-northeast-1` | recommended | [§2](#2-base-url-and-region) |
| `Idempotency-Key` | recommended on writes | 1–255 printable ASCII characters; [§12](#12-idempotency) |
| `X-ViperFlow-Integration` | optional | Lowercase slug identifying your integration (`^[a-z0-9][a-z0-9_.-]{0,63}$`, e.g. `woocommerce`). Recorded as `origin.integration` on the webhook events your writes cause — useful to ignore your own echoes. Malformed → `400 invalid_request`. |
| `User-Agent` | recommended | Shown (truncated to 200 characters) in the request log |

**Bodies**

- JSON only, UTF-8, at most **1 MB** (`413 payload_too_large`). Invalid JSON, an empty body where
  one is required, nesting deeper than 20 levels or more than 1000 array elements in total →
  `400 invalid_json`.
- Objects are strict: an unknown field fails with `422 validation_failed`
  (`details.fields["<path>"] = "unknown_field"`).
- Strings are trimmed. For optional text fields an empty string is stored as `null`.
- `POST /v1/orders/{id}/confirm`, `POST /v1/orders/{id}/cancel` and `DELETE` requests take no
  body; one sent anyway is ignored.

**Query strings**

- Every endpoint accepts only its documented parameters; an unknown parameter or a parameter
  given twice → `400 invalid_request` with `details: {"parameter", "reason"}`.
- Date-time values must include a time zone offset. In a query string `+` means a space, so
  send `+03:00` as `%2B03:00` (or use `Z`).
- Path ids must be UUIDs; a malformed id is `404 not_found`, indistinguishable from a missing one.

## 7. Responses

| Shape | Body |
|---|---|
| Single object | `{"data": {…}}` — `200 OK`, or `201 Created` for creates |
| List | `{"data": […], "pagination": {"limit": 25, "has_more": true, "next_cursor": "…"}}` |
| No content | `204 No Content`, empty body (`DELETE /v1/external-refs/{id}`) |
| Test-mode write | same as live, plus top-level `"livemode": false` |
| Error | `{"error": {"code", "message", "details"?, "request_id"}}` ([§8](#8-errors)) |

Every object carries `"object"` (`"customer"`, `"order"`, …) and ids are UUIDs. Objects never
contain internal fields such as the account id, user ids of employees, cost prices, internal
payment notes, cheque branch/account numbers or share tokens.

**Response headers**

| Header | When |
|---|---|
| `X-Request-Id: req_<24 hex>` | always — quote it when contacting support; also in `error.request_id` and in the request log |
| `X-ViperFlow-Api-Version: v1` | always |
| `X-ViperFlow-Environment: live\|test` | authenticated requests (including a `429` for a valid key over its limit) |
| `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | authenticated requests, and a `429` from the failed-authentication bucket ([§9](#9-rate-limits)). A plain `401` carries no rate headers |
| `Retry-After` | `429`, and `503` (5 seconds) |
| `Idempotent-Replayed: true` | the response is a stored idempotent replay ([§12](#12-idempotency)) |
| `X-ViperFlow-Dry-Run: true` | test-mode writes |
| `Allow` | `405 method_not_allowed` |
| `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` | always |

## 8. Errors

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The request body failed validation.",
    "details": {
      "fields": { "items[0].quantity": "too_small", "delivery_date": "invalid_instant" },
      "errors": [
        { "path": "items[0].quantity", "code": "too_small", "message": "Must be at least 1." },
        { "path": "delivery_date", "code": "invalid_instant", "message": "Must be an ISO-8601 date-time with a time zone offset (e.g. 2026-09-14T10:00:00+03:00)." }
      ]
    },
    "request_id": "req_4f1c2a9b7e3d5c6a8b9e0f12"
  }
}
```

- Branch on `error.code` (stable). `error.message` is human-readable English and may change.
  Business rules enforced inside the database (for example a discount guard, or a status
  transition refused by `confirm`) pass the database's own message through, which is often
  **Hebrew**.
- `details` is omitted when empty. Unexpected server errors never expose internals: you get
  `500 internal_error` with the `request_id`, and the full error is kept in ViperFlow's logs.

| Status | Code | When | `details` |
|---|---|---|---|
| 400 | `invalid_request` | Unknown/duplicate/malformed query parameter; malformed `Idempotency-Key` or `X-ViperFlow-Integration` header; invalid input rejected by the database | `parameter`, `reason` (query) or `header` |
| 400 | `invalid_json` | Body is not valid UTF-8 JSON, is empty, or exceeds depth 20 / 1000 array elements | `reason`: `too_deep` \| `too_many_array_elements` |
| 400 | `invalid_cursor` | `cursor` is malformed or was issued for a different `sort` | `reason`: `malformed` \| `sort_mismatch` |
| 401 | `missing_api_key` | No `Authorization` header | |
| 401 | `invalid_api_key` | Malformed or unknown key | |
| 401 | `api_key_revoked` | Key revoked or its integration user deactivated | |
| 401 | `api_key_expired` | Key past its expiry date | |
| 403 | `insufficient_scope` | The key lacks the scope the endpoint requires | `required_scope` |
| 403 | `module_not_enabled` | The scope depends on a module your plan does not include | `required_scope` |
| 403 | `forbidden` | A database permission check refused the operation | |
| 404 | `not_found` | Unknown path, malformed path id, or a record that does not exist or is not visible to the key | |
| 405 | `method_not_allowed` | Known path, unsupported method | `allowed_methods` |
| 409 | `invalid_state_transition` | The record's status does not allow the operation (e.g. confirming a cancelled order, editing a delivered order) | `status`, `allowed_statuses` (PATCH order) |
| 409 | `conflict` | Concurrent modification, duplicate, or an idempotent request still in progress — safe to retry | |
| 409 | `external_ref_conflict` | The external id is already linked to another record | `existing_entity_id` |
| 413 | `payload_too_large` | Body over 1 MB | `max_bytes` |
| 415 | `unsupported_media_type` | Body sent without `Content-Type: application/json` | |
| 422 | `validation_failed` | Body failed validation, or a referenced id/reference in the body was not found | `fields`, `errors` |
| 422 | `business_rule_violation` | A business rule refused the change (message from the database) | |
| 422 | `idempotency_key_mismatch` | `Idempotency-Key` reused with a different method, path or body | |
| 422 | `discount_requires_approval` | An order or line discount exceeds the account's approval threshold | `threshold_percent`, `fields` |
| 429 | `rate_limited` | A rate limit was exceeded; wait `Retry-After` seconds | |
| 500 | `internal_error` | Unexpected error | |
| 503 | `timeout` | The operation exceeded the 15-second database time limit; retry | |
| 503 | `service_unavailable` | Temporary database or network unavailability; retry after `Retry-After` (5 s) | |

**`validation_failed` field codes** (`details.fields` values and `details.errors[].code`):

| Code | Meaning |
|---|---|
| `required` | Missing required field (or empty required string) |
| `unknown_field` | Field not accepted by this endpoint |
| `invalid_type` | Wrong JSON type |
| `too_short`, `too_long` | String length out of range |
| `too_small`, `too_large`, `not_integer`, `too_many_decimals` | Number out of range / precision |
| `invalid_enum` | Value not in the allowed list |
| `invalid_uuid`, `invalid_date`, `invalid_instant`, `invalid_email`, `invalid_format`, `invalid_characters` | Malformed value (control characters are rejected in all strings) |
| `invalid_slug` | Integration slug does not match `^[a-z0-9][a-z0-9_.-]{0,63}$` |
| `invalid_tin` | Israeli business/ID number fails the check digit |
| `too_many_items`, `too_few_items` | Array length out of range |
| `no_fields` | A `PATCH` body that changes nothing |
| `conflicting_fields` | Two mutually exclusive fields were both sent (e.g. `customer_id` and `customer_ref`) |
| `not_allowed` | Field not allowed in this combination (e.g. `check` on a cash payment) |
| `not_found` | A referenced id or external reference in the body does not exist or is not visible |
| `multiple_primary`, `primary_required` | Contact primary-flag rules ([§15.3](#15-3-customers)) |
| `before_delivery_date`, `after_return_date` | `delivery_date` must not be later than `return_date` |
| `empty_order` | The order has no item, custom item or logistics line with quantity above 0 |
| `product_inactive` | The product is inactive |
| `unknown_option`, `not_selectable`, `duplicate_choice_group`, `incomplete_selection` | Invalid product option selection ([§15.4](#post-v1orders)) |
| `exceeds_total` | Deposit larger than the order total |
| `amount_too_large` | Computed amounts exceed the supported maximum |

**Query `reason` values** (`400 invalid_request`): `unknown_parameter`, `duplicate_parameter`,
`required`, `invalid_value`, `too_many_values`, `conflicting_parameters`.

**Retrying.** Retry `409 conflict`, `429`, `500`, `503` and network errors with exponential
backoff, reusing the same `Idempotency-Key` for writes. Do not retry other `4xx` responses
unchanged.

## 9. Rate limits

Limits use fixed 60-second windows. Every authenticated request counts against these buckets:

| Bucket | Limit per 60 s | Applies to |
|---|---|---|
| Per key | the key's `rate_limit_per_minute` (default 120, configurable 10–1000) | all requests of the key |
| Per key, writes | 60 | `POST`, `PATCH`, `PUT`, `DELETE` of the key |
| Per account | 600 | all keys of the account together |
| Per IP address, failed authentication | 30 | requests with a missing-prefix/unknown/revoked/expired key |

- `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds) describe
  the **most restrictive** bucket for this request.
- Over a limit → `429 rate_limited` with `Retry-After` (seconds). Replayed idempotent requests
  and test-mode writes count too.
- While an IP address is over the failed-authentication limit, every request from it that does
  not carry a valid key receives `429` before the key is checked. A valid key is never blocked by
  this bucket. (A request without any `Authorization` header gets `401` and does not count.)
- Client IP detection is best effort (the first present of `cf-connecting-ip`, `x-real-ip`,
  `x-forwarded-for`).

## 10. Pagination, sorting and filtering

List endpoints use **keyset (cursor) pagination**:

| Parameter | Default | Notes |
|---|---|---|
| `limit` | 25 | 1–100 |
| `sort` | `-created_at` | `-created_at`, `created_at`, `-updated_at`, `updated_at` (`/v1/inventory/movements`: created_at sorts only). `-` = newest first |
| `cursor` | — | Opaque; copy `pagination.next_cursor` from the previous page. A cursor belongs to the sort it was issued for (`400 invalid_cursor` otherwise). Keep all other parameters identical while paging. |

Common filters (where listed per endpoint):

| Parameter | Format | Meaning |
|---|---|---|
| `created_since` | ISO-8601 date-time with offset | `created_at >=` value |
| `updated_since` | ISO-8601 date-time with offset | `updated_at >=` value |
| `ids` | comma-separated UUIDs, ≤ 100 | only these records (unknown/invisible ids are simply absent) |
| `external_ref` | `<integration>:<external_id>` | the record linked to that external id ([§14](#14-external-references)); split at the first colon |

Comma-separated enum filters (`status=draft,confirmed`) accept any subset of the documented
values; an unknown value is `400 invalid_request`, never an empty page. Date ranges
(`event_from`/`event_to`, `date_from`/`date_to`, `scheduled_from`/`scheduled_to`) are inclusive
calendar dates `YYYY-MM-DD`; from later than to → `400` with reason `conflicting_parameters`.

Pages are stable: paging through a list never skips or repeats a record whose sort column does not
change meanwhile. A record whose `updated_at` changes during an `updated_at` sort may appear again
on a later page — de-duplicate by `id`.

## 11. Incremental sync recipe

To mirror, say, orders into your system:

1. **Backfill.** `GET /v1/orders?sort=updated_at&limit=100`, follow `next_cursor` until
   `has_more` is `false`. Upsert each order by `id`. Remember the largest `updated_at` you saw.
2. **Poll.** Every few minutes:
   `GET /v1/orders?sort=updated_at&limit=100&updated_since=<largest updated_at − 2 minutes>` and
   page through. Upserts are idempotent, so the 2-minute overlap is harmless — it covers
   transactions that committed after later changes became visible (`updated_at` is the start time
   of the change's transaction).
3. **Webhooks as a trigger.** Subscribe to `order.*` events and fetch the order when one arrives;
   keep a slow poll as a safety net ([`WEBHOOKS.md`](WEBHOOKS.md)).

Things to know:

- **Deletions are observable only through webhooks** (`order.deleted`, `customer.deleted`, …).
  Deleted records simply disappear from lists. If you cannot receive webhooks, reconcile
  periodically with `ids=` (up to 100 per call): ids missing from the response are gone (or no
  longer visible to the key).
- **Order item ids are not stable.** When an order is edited in the web app its lines are
  rewritten and receive new ids. Treat `items` as a whole: replace your copy of an order's items
  on each sync, keyed by order id — never update individual items by item id.
- Child changes move the parent's `updated_at`: order lines → the order; customer contacts → the
  customer; product components and variant prices → the product; supplier contacts → the
  supplier; purchase order lines → the purchase order. Stock changes move the product's
  `updated_at` (and so `GET /v1/inventory?updated_since=`).
- Recalculated customer statistics (`stats`) also move `customers.updated_at`, so polling
  customers returns customers whose only change is their statistics (no webhook event is sent for
  statistics).
- Inventory movements are append-only: sync them with `created_since` and `sort=created_at`.

## 12. Idempotency

Send `Idempotency-Key: <unique value>` (1–255 printable ASCII characters; a UUID v4 is ideal) on
any `POST`, `PATCH`, `PUT` or `DELETE` so a retry after a timeout or network error cannot create
a second order or payment.

- Keys are scoped to the API key and remembered for **24 hours** from first use.
- The key is bound to the **method, path and body**. The body is compared canonically (key order
  and whitespace do not matter).
- Same key + same method, path and body after the first request **succeeded** → the stored
  response (same status and body) is returned with `Idempotent-Replayed: true`; nothing runs again.
- Same key with a different method, path or body → `422 idempotency_key_mismatch`.
- Only successful responses are stored. If the first request failed (any `4xx`/`5xx`), nothing
  was saved — the key is free and a retry (even with a corrected body) runs normally.
- Two identical requests racing with the same key: the second waits for the first and then
  replays its response (or runs, if the first failed). A `409 conflict` saying the key is still
  being processed can be retried after a short delay.
- Test-mode keys ignore `Idempotency-Key` (nothing is stored).
- `GET` requests ignore the header.

For protection beyond 24 hours (for example re-importing the same store order days later),
attach an external reference to created records ([§14](#14-external-references)): a second create
with the same `external_ref` fails atomically with `409 external_ref_conflict`.

## 13. Dates, money and units

| Kind | Output | Input |
|---|---|---|
| Date-time | ISO-8601 UTC with milliseconds: `2026-09-14T07:30:00.000Z` | ISO-8601 **with an offset** (`Z` or `±HH:MM`), e.g. `2026-09-14T10:30:00+03:00`; stored and returned in UTC. Offset-less values are rejected. |
| Calendar date | `YYYY-MM-DD` | `YYYY-MM-DD` |
| Time of day | `HH:MM` (warehouse task `scheduled_start`) | — |
| Money | JSON number, 2 decimals, currency `ILS` (`"currency": "ILS"` on money-bearing objects) | JSON number ≥ 0 with at most 2 decimals, at most 10,000,000 |
| Percent | 0–100, 2 decimals | 0–100, at most 2 decimals |
| VAT rate | fraction with 4 decimals (`0.18`) | — |
| Quantity | integer for orders and products; up to 3 decimals for purchase order and document lines | integer |

- An order's `event.date` is a calendar day in Israel, independent of time zones.
  `delivery_date`/`return_date` are instants. An instant at exactly `00:00:00Z` is the app's
  "this day, no hour chosen": the app offers set-up / take-down that way (the day before / after
  the event) and shows it as a date alone. To mean exactly 00:00 UTC as a time (03:00 / 02:00 in
  Israel), send it one second later (`…T00:00:01Z`).
- `paid_from`/`paid_to` on `GET /v1/payments` accept either a calendar date (a whole day in
  Asia/Jerusalem, inclusive) or an instant.
- Order totals follow the web app exactly: line discounts, then the order discount on catalog and
  custom items (not on logistics), VAT with the account's VAT rate, and the payable total rounded
  **down** to a whole shekel; the shaved agorot are included in `totals.total_discount`.

## 14. External references

External references link a ViperFlow record to its id in your system, so you never have to store
ViperFlow ids to find records again.

- A reference is `(integration, entity_type, entity_id, external_id)`.
  `integration` is your slug (`woocommerce`, `shopify`, `my-erp`, …; lowercase, no colon),
  `entity_type` ∈ `customer`, `order`, `product`, `payment`, `supplier`, `purchase_order`,
  `document`, `external_id` is any 1–255 character string.
- Within one account, integration and entity type: one external id maps to one record, and a
  record has at most one external id.
- References are returned in each object's `external_refs: [{integration, external_id}]`
  (customers, orders, products, payments, suppliers, purchase orders).
- References are deleted automatically when their record is deleted. A reference pointing to a
  record that no longer exists is overwritten by a new link.

**Example: WooCommerce order #12345**

1. Import the order, linking customer, products and the order itself in one request:

   ```json
   POST /v1/orders
   {
     "customer_ref": { "integration": "woocommerce", "external_id": "customer-881" },
     "event_date": "2026-10-02",
     "delivery_date": "2026-10-01T14:00:00+03:00",
     "return_date": "2026-10-03T10:00:00+03:00",
     "items": [
       { "product_ref": { "integration": "woocommerce", "external_id": "sku-TBL-180" }, "quantity": 10 }
     ],
     "external_ref": { "integration": "woocommerce", "external_id": "12345" }
   }
   ```

   If order 12345 was already imported, the whole request fails with `409 external_ref_conflict`
   and `details.existing_entity_id` — nothing is created.
2. Find it later: `GET /v1/orders?external_ref=woocommerce:12345`, or resolve many ids at once
   with `GET /v1/external-refs?integration=woocommerce&entity_type=order&external_id=12345,12346`.
3. Link records created in ViperFlow itself: `PUT /v1/external-refs` with
   `{"integration":"woocommerce","entity_type":"product","entity_id":"<uuid>","external_id":"sku-TBL-180"}`.

Permissions: a reference embedded in a create body (`external_ref`) needs only that create
scope. `PUT`/`DELETE /v1/external-refs` need `external_refs:write`, and the key must also be able
to read the linked record (payments: `payments:read` or `payments:write`; documents:
`documents:read`; suppliers and purchase orders: `suppliers:read` or `purchase_orders:read`);
otherwise the record counts as not found. `GET /v1/external-refs` needs the entity type's read
scope. `X-ViperFlow-Integration` (a header) and external references are independent.

---

## 15. Endpoint reference

Every endpoint below lists its scope, parameters, body and responses. Common error responses
(`400`, `401`, `403`, `404`, `413`, `415`, `422`, `429`, `500`, `503`) are described in
[§8](#8-errors) and apply wherever relevant. Examples are abbreviated where marked `…`.

### 15.1 Meta

### GET /v1/health

No authentication, no scope, no rate limit. CORS-enabled (`Access-Control-Allow-Origin: *`).
Not wrapped in `data`.

```json
{ "status": "ok", "api_version": "v1", "time": "2026-09-15T08:00:00.000Z" }
```

### GET /v1/me

Any valid key. Returns the key's identity and account. No parameters.

```json
{
  "data": {
    "object": "api_key_identity",
    "api_key": {
      "id": "0d6c8f9e-2b1a-4c3d-9e8f-7a6b5c4d3e2f",
      "name": "WooCommerce sync",
      "prefix": "vf_live_Ab3dE9xQ",
      "environment": "live",
      "scopes": ["customers:write", "orders:write", "products:read"],
      "rate_limit_per_minute": 120,
      "expires_at": null,
      "created_at": "2026-09-01T09:12:44.120Z"
    },
    "tenant": { "id": "4456fda3-1c2b-4d5e-8f90-a1b2c3d4e5f6", "name": "אירועי הצפון" },
    "livemode": true
  }
}
```

### 15.2 Common list parameters

Unless an endpoint says otherwise, lists accept `limit`, `cursor`, `sort` (four sorts),
`created_since`, `updated_since` and `ids` ([§10](#10-pagination-sorting-and-filtering)).

### 15.3 Customers

#### Customer object

```json
{
  "id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "object": "customer",
  "customer_number": 1042,
  "display_number": "1042",
  "business_name": "אולמי הגן בע\"מ",
  "business_id": "515123453",
  "default_discount_percent": 5,
  "address": { "street": "הרצל 10", "city": "חיפה", "zip": "3303110" },
  "notes": null,
  "contacts": [
    { "id": "8c2d4e6f-1a3b-4c5d-8e9f-0a1b2c3d4e5f", "name": "דנה כהן", "role": "מנהלת אירועים", "phone": "050-1234567", "email": "dana@example.co.il", "is_primary": true }
  ],
  "stats": { "total_orders": 12, "total_revenue": 48210, "total_outstanding": 3120, "last_order_at": "2026-09-10T08:00:00.000Z" },
  "external_refs": [ { "integration": "woocommerce", "external_id": "customer-881" } ],
  "created_at": "2025-11-02T10:15:00.000Z",
  "updated_at": "2026-09-10T08:00:03.412Z"
}
```

- `contacts` are ordered primary first, then by creation. A customer with contacts always has
  exactly one primary contact.
- `display_number` is `customer_number` zero-padded to 4 digits.

### GET /v1/customers

Scope `customers:read`. Common list parameters, plus:

| Parameter | Notes |
|---|---|
| `search` | ≤ 200 characters. Case-insensitive substring match on business name, business id, contact name/phone/email; an all-digit value also matches `customer_number` exactly |
| `external_ref` | `<integration>:<external_id>` |

Response: `{"data": [Customer, …], "pagination": {…}}`.

### GET /v1/customers/{id}

Scope `customers:read`. Response `200 {"data": Customer}`; `404 not_found`.

### POST /v1/customers

Scope `customers:write`. Idempotent with `Idempotency-Key`. Response `201 {"data": Customer}`.

| Field | Type | Rules |
|---|---|---|
| `business_name` | string | **required**, 1–200 |
| `business_id` | string \| null | Israeli company/dealer/ID number; non-digits are stripped and it is left-padded to 9 digits; must pass the check digit (`invalid_tin`) |
| `default_discount_percent` | number | 0–100, default 0. Applied as the default order discount of new orders |
| `address` | object \| null | `{street?, city?, zip?}`, each ≤ 200 |
| `notes` | string \| null | ≤ 4000 |
| `contacts` | array | ≤ 20 of `{name (1–120, required), role? (≤120), phone? (≤40), email? (valid, ≤254), is_primary? (boolean)}`. At most one `is_primary: true` (`multiple_primary`); when none is flagged the first contact becomes primary |
| `external_ref` | object | `{integration, external_id}` ([§14](#14-external-references)) |

```json
{
  "business_name": "אולמי הגן בע\"מ",
  "business_id": "515123453",
  "address": { "street": "הרצל 10", "city": "חיפה" },
  "contacts": [ { "name": "דנה כהן", "phone": "050-1234567", "email": "dana@example.co.il" } ],
  "external_ref": { "integration": "woocommerce", "external_id": "customer-881" }
}
```

### PATCH /v1/customers/{id}

Scope `customers:write`. Send only the fields to change; `null` clears a nullable field. Response
`200 {"data": Customer}`.

Fields: `business_name`, `business_id`, `default_discount_percent`, `address` (the whole address
object; `null` clears street, city and zip; omitted inner keys keep their value), `notes` — same
rules as create. At least one field (`no_fields`).

### POST /v1/customers/{id}/contacts

Scope `customers:write`. Body `{name, role?, phone?, email?, is_primary?}` (rules as above).
Response **`201 {"data": Customer}`** (the whole customer).

- `is_primary: true` makes the new contact primary and clears the previous primary.
- A customer without contacts gets the new contact as primary even with `is_primary: false`.

### PATCH /v1/customers/{id}/contacts/{contact_id}

Scope `customers:write`. Body: any of `name`, `role`, `phone`, `email`, `is_primary`. Response
`200 {"data": Customer}`; `404` when the contact does not belong to that customer.

- `is_primary: true` moves the primary flag to this contact.
- `is_primary: false` on the current primary contact → `422` `primary_required` (mark another
  contact as primary instead).

### 15.4 Orders

Order statuses: `draft` → `confirmed` → `picked` → `delivered` → `returned`, and `cancelled`
(from `draft` or `confirmed`). There is no `completed` status: the lifecycle ends at `returned`.
Payment statuses: `unpaid`, `partial`, `paid`.

#### Order object

`GET /v1/orders` returns orders **without** `items`; `GET /v1/orders/{id}` and every order write
include them.

```json
{
  "id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d",
  "object": "order",
  "order_number": "ORD-2026-0412",
  "status": "draft",
  "customer_id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "customer_name": "אולמי הגן בע\"מ",
  "contact_id": "8c2d4e6f-1a3b-4c5d-8e9f-0a1b2c3d4e5f",
  "event": { "date": "2026-10-02", "location": "גן האירועים, חיפה" },
  "delivery_date": "2026-10-01T11:00:00.000Z",
  "return_date": "2026-10-03T07:00:00.000Z",
  "buffer_hours": { "before": 3, "after": 3 },
  "event_confirmed": false,
  "currency": "ILS",
  "totals": { "subtotal": 3600, "total_discount": 0, "taxable_amount": 3600, "vat_amount": 648, "grand_total": 4248, "order_discount_percent": 0 },
  "payment": { "status": "unpaid", "type": "full", "due_date_option": "net30", "due_date": null, "deposit_amount": 0, "paid_amount": 0, "pending_amount": 0, "balance_due": 4248 },
  "notes": null,
  "picking_completed_at": null,
  "return_completed_at": null,
  "spec_changed_after_pick": false,
  "item_count": 3,
  "items": [
    {
      "id": "f0e1d2c3-b4a5-4968-8776-655443322110",
      "parent_item_id": null,
      "line_type": "product",
      "is_component": false,
      "component_type": null,
      "product_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d",
      "name": "שולחן עגול 1.8",
      "quantity": 10,
      "unit_price": 120,
      "discount_percent": 0,
      "line_total": 1200,
      "options": [ { "group_id": "2f3e4d5c-6b7a-4891-a2b3-c4d5e6f7a8b9", "group_name": "מפה", "child_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "value": "מפה לבנה" } ],
      "is_custom": false,
      "is_inventory_tracked": true,
      "notes": null,
      "spare_quantity": 0,
      "picked_quantity": 0,
      "spare_picked_quantity": 0,
      "returned_quantity": 0,
      "return_status": null,
      "missing_quantity": 0,
      "broken_quantity": 0,
      "sort_order": 0
    },
    { "id": "…", "line_type": "worker", "name": "סידור ואיסוף", "quantity": 0, "unit_price": 1500, "line_total": 0, "…": "…" },
    { "id": "…", "line_type": "truck", "name": "הובלה", "quantity": 1, "unit_price": 2400, "line_total": 2400, "…": "…" }
  ],
  "external_refs": [ { "integration": "woocommerce", "external_id": "12345" } ],
  "created_at": "2026-09-15T08:10:00.000Z",
  "updated_at": "2026-09-15T08:10:00.000Z"
}
```

Item notes:

- `line_type`: `product` for catalog and custom items; `worker` and `truck` for the logistics
  rows (every order has one of each, possibly with quantity 0); other values the web app may add
  for extra services.
- Components of a set appear right after their parent with `is_component: true`,
  `parent_item_id` set and `component_type` ∈ `mandatory`, `optional`, `choice_group`,
  `combination`.
- `options` lists the selected options of a catalog item: `group_id`/`group_name` for a choice
  group (null for an optional component), `child_id` = the selected product component id (null
  when the option no longer exists in the catalog), `value` = the label as stored on the order.
- `item_count` counts every row, including components and zero-quantity logistics rows.
- Item ids are not stable across web-app edits ([§11](#11-incremental-sync-recipe)).
- `buffer_hours` values of `null` mean the account default applies.

### GET /v1/orders

Scope `orders:read`. Common list parameters, plus:

| Parameter | Notes |
|---|---|
| `status` | comma list of `draft`, `confirmed`, `picked`, `delivered`, `returned`, `cancelled` |
| `payment_status` | comma list of `unpaid`, `partial`, `paid` |
| `customer_id` | UUID |
| `event_from`, `event_to` | inclusive `YYYY-MM-DD` on `event.date` |
| `external_ref` | `<integration>:<external_id>` |

### GET /v1/orders/{id}

Scope `orders:read`. Response `200 {"data": Order}` with `items`.

### POST /v1/orders

Scope `orders:write`. Idempotent with `Idempotency-Key`. Creates a **draft** order exactly as the
web app's order wizard does: same prices, set/component expansion, option validation, logistics
rows, totals, due date and payment schedule. Response `201 {"data": Order}` with `items`.

| Field | Type | Rules |
|---|---|---|
| `customer_id` | UUID | one of `customer_id` / `customer_ref` is **required** |
| `customer_ref` | `{integration, external_id}` | the customer linked to that external id |
| `contact_id` | UUID \| null | must belong to the customer. **Omitted → the customer's primary contact**; `null` → no contact |
| `event_date` | `YYYY-MM-DD` | **required** |
| `delivery_date` | date-time with offset | **required** |
| `return_date` | date-time with offset | **required**; not earlier than `delivery_date` (`before_delivery_date`) |
| `event_location` | string \| null | ≤ 300 |
| `notes` | string \| null | ≤ 4000 |
| `buffer_hours` | `{before?, after?}` | integers 0–168; omitted → the account defaults |
| `order_discount_percent` | number | 0–100; **omitted → the customer's `default_discount_percent`** |
| `items` | array | ≤ 200 catalog items (below) |
| `custom_items` | array | ≤ 50 of `{name (1–300), quantity (1–100000), unit_price (money), discount_percent?, notes? (≤1000)}` |
| `logistics` | object | `{worker?: {quantity (0–1000), unit_price?}, truck?: {quantity, unit_price?}}`; default prices are the account's defaults (1,500 / 2,400 when unset); rows are labelled `סידור ואיסוף` / `הובלה` |
| `payment` | object | payment terms (below); omitted → full payment |
| `external_ref` | `{integration, external_id}` | [§14](#14-external-references) |

At least one item, custom item or logistics quantity above 0 is required (`empty_order`).

Catalog item:

| Field | Type | Rules |
|---|---|---|
| `product_id` / `product_ref` | UUID / `{integration, external_id}` | exactly one; the product must exist and be active (`not_found`, `product_inactive`) |
| `quantity` | integer | 1–100000 |
| `discount_percent` | number | 0–100, default 0 |
| `spare_quantity` | integer | 0–100000, default 0 (spares to pick) |
| `notes` | string \| null | ≤ 1000 |
| `options` | UUID[] | ≤ 50 product component ids: the chosen optional components and one option per choice group. When the product defines option combinations, the chosen choice options must equal one defined combination (`incomplete_selection`). Other errors: `unknown_option` (not a component of this product), `not_selectable` (a mandatory component), `duplicate_choice_group` |
| `unit_price` | money | optional explicit unit price; omitted → the catalog price computed like the wizard (combination price, else base price plus option price deltas; combination-reserved components priced from the reserved product when the combination has no custom price) |

Payment terms:

| Field | Rules |
|---|---|
| `type` | `full` \| `net` \| `deposit` (required) |
| `deposit_amount` | required for `deposit` (> 0, not above the order total — `exceeds_total`); not allowed otherwise |
| `due_date_option` | `net` (end of this month), `net30`, `net60`, `net90` (end of month + N days), `specific`; not allowed for `full`; default `net30` |
| `due_date` | `YYYY-MM-DD`, required for and only allowed with `specific` |

Due dates are computed from today's date in Israel. When the key holds `payments.schedule.manage`
(part of `orders:write`, removed when the plan has no payments module) the payment schedule rows
are created as in the wizard (deposit + balance, or one payment).

**Discount approval.** If the effective order discount or any item/custom-item discount exceeds
the account's discount approval threshold, the order is refused before anything is written:
`422 discount_requires_approval` with `details.threshold_percent` and `details.fields` (which
discounts, e.g. `customer.default_discount_percent` when the discount came from the customer).
API keys cannot approve high discounts.

**Availability is not enforced.** Like the web app (which only warns), the API does not refuse an
order when stock is short.

```json
{
  "customer_id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "event_date": "2026-10-02",
  "delivery_date": "2026-10-01T14:00:00+03:00",
  "return_date": "2026-10-03T10:00:00+03:00",
  "event_location": "גן האירועים, חיפה",
  "items": [
    { "product_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d", "quantity": 10, "options": ["9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"] }
  ],
  "custom_items": [ { "name": "עיצוב פרחים", "quantity": 1, "unit_price": 850 } ],
  "logistics": { "truck": { "quantity": 1 } },
  "payment": { "type": "deposit", "deposit_amount": 1000, "due_date_option": "net30" },
  "external_ref": { "integration": "woocommerce", "external_id": "12345" }
}
```

### PATCH /v1/orders/{id}

Scope `orders:write`. Updates header fields of a `draft` or `confirmed` order; any other status →
`409 invalid_state_transition` (`details.status`, `details.allowed_statuses`). Items, prices and
payment terms cannot be changed through the API. Response `200 {"data": Order}`.

| Field | Rules |
|---|---|
| `notes`, `event_location` | as in create; `null` clears |
| `contact_id` | UUID of a contact of the order's customer, or `null` |
| `event_date` | `YYYY-MM-DD` |
| `delivery_date`, `return_date` | date-time with offset; the resulting window must keep delivery ≤ return (`before_delivery_date` / `after_return_date`) |
| `buffer_hours` | `{before?, after?}`, integers 0–168 or `null` (= account default) |
| `event_confirmed` | boolean |

### POST /v1/orders/{id}/confirm

Scope `orders:write`. No body. `draft` → `confirmed` through the same transition the web app uses
(warehouse picking tasks, field tasks and activity entries are created by the usual rules).
Confirming an already `confirmed` order returns it unchanged. Any other status →
`409 invalid_state_transition` (message from the database, in Hebrew). Response
`200 {"data": Order}`.

### POST /v1/orders/{id}/cancel

Scope `orders:write`. No body. `draft` or `confirmed` → `cancelled` (open warehouse and field tasks
of the order are removed by the usual rules). Cancelling an already `cancelled` order returns it
unchanged; `picked`, `delivered` or `returned` orders → `409 invalid_state_transition`. Response
`200 {"data": Order}`.

### 15.5 Products and categories

#### Product object

`GET /v1/products` omits `components` and `variant_prices`; `GET /v1/products/{id}` includes them.

```json
{
  "id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d",
  "object": "product",
  "name": "שולחן עגול 1.8",
  "description": null,
  "category_id": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b",
  "base_price": 120,
  "currency": "ILS",
  "is_active": true,
  "is_inventory_tracked": true,
  "is_primary": true,
  "is_new": false,
  "show_in_warehouse": true,
  "dimensions": "180 ס\"מ",
  "default_image_url": "https://…/table.webp",
  "low_stock_threshold": 5,
  "warehouse_entry_date": "2025-03-01",
  "components": [
    { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "type": "choice_group", "child_product_id": "…", "name": "מפה לבנה", "quantity": 1, "choice_group": { "id": "2f3e4d5c-6b7a-4891-a2b3-c4d5e6f7a8b9", "name": "מפה" }, "price_delta": 15, "is_inventory_tracked": true, "sort_order": 0 }
  ],
  "variant_prices": [ { "option_ids": ["9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"], "price": 140, "image_url": null } ],
  "external_refs": [],
  "created_at": "2025-03-01T09:00:00.000Z",
  "updated_at": "2026-08-20T12:30:00.000Z"
}
```

- `components[].type` ∈ `mandatory`, `optional`, `choice_group`; `choice_group` is set only for
  choice-group options; `price_delta` is the option's price override (null when none).
- `variant_prices` are option combinations with their own price (`price` null = no custom price)
  and image.

### GET /v1/products

Scope `products:read`. Common list parameters, plus `search` (≤ 200, product name only),
`category_id` (exact category, sub-categories not included), `is_active` (`true`/`false`; default:
all), `external_ref`.

### GET /v1/products/{id}

Scope `products:read`. Response `200 {"data": Product}` with components and variant prices.

### GET /v1/categories

Scope `products:read`. No parameters. All categories in one page, ordered by `sort_order`, then
name; list envelope with `has_more: false` and `limit: 1000` (an account with more than 1000
categories gets the first 1000 and `has_more: true`).

```json
{ "data": [ { "id": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b", "object": "category", "name": "שולחנות", "parent_id": null, "sort_order": 1, "created_at": "2025-01-10T08:00:00.000Z" } ],
  "pagination": { "limit": 1000, "has_more": false, "next_cursor": null } }
```

### 15.6 Inventory

#### Inventory item object

One per product:

```json
{
  "object": "inventory_item",
  "product_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d",
  "name": "שולחן עגול 1.8",
  "is_active": true,
  "is_inventory_tracked": true,
  "total_quantity": 120,
  "in_maintenance_quantity": 4,
  "on_hand_quantity": 116,
  "reservations_included": false,
  "low_stock_threshold": 5,
  "is_low_stock": false,
  "updated_at": "2026-09-14T15:02:11.000Z"
}
```

- `on_hand_quantity` = total − in maintenance (`null` for untracked products).
- **Reservations are not included** (`reservations_included: false`): quantities booked by orders
  for specific dates are not subtracted. Date-window availability is not part of API v1.
- `is_low_stock`: active, tracked, threshold set, and on-hand ≤ threshold.

### GET /v1/inventory

Scope `inventory:read`. Parameters: `limit`, `cursor`, `sort` (four sorts, on the product's
`created_at`/`updated_at`), `updated_since`, `product_ids` (comma UUIDs ≤ 100), `is_active`,
`low_stock` (`true`/`false`), `include_untracked` (default `false`: tracked products only).

### GET /v1/inventory/movements

Scope `inventory:read`. Parameters: `limit`, `cursor`, `sort` (`-created_at` default, `created_at`),
`created_since`, `product_id`, `change_type` (comma list of `add`, `remove`, `maintenance_in`,
`maintenance_out`, `count`).

```json
{ "id": "6d5c4b3a-2918-4776-8554-433221100fed", "object": "inventory_movement", "product_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d", "product_name": "שולחן עגול 1.8",
  "change_type": "add", "quantity_before": 110, "quantity_change": 10, "quantity_after": 120, "note": "רכש", "stock_count_id": null, "created_at": "2026-09-14T15:02:11.000Z" }
```

### 15.7 Payments

#### Payment object

```json
{
  "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "object": "payment",
  "order_id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d",
  "customer_id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "direction": "in",
  "amount": 1000,
  "currency": "ILS",
  "method": "check",
  "status": "pending",
  "paid_at": "2026-09-15T08:30:00.000Z",
  "reference": "שיק 000123",
  "check": { "number": "000123", "bank": "לאומי", "due_date": "2026-10-01" },
  "voided": false,
  "voided_at": null,
  "external_refs": [],
  "created_at": "2026-09-15T08:30:00.000Z",
  "updated_at": "2026-09-15T08:30:00.000Z"
}
```

- `direction`: `in` (received) or `out` (refund). `method`: `cash`, `credit_card`,
  `bank_transfer`, `check`, `bit`, `offset`, `other`.
- `status`: `pending`, `deposited`, `cleared`, `bounced`, `cancelled`. Only `cleared` payments
  count as paid; pending cheques count as `pending_amount` on the order.
- `check` is set only for cheques. Branch and account numbers are never returned.
- Voided payments stay in the ledger with `voided: true`.

### GET /v1/payments

Scope `payments:read`. Common list parameters, plus `order_id`, `customer_id`, `status` (comma
list), `paid_from`, `paid_to` (date = whole Israel day, or date-time; inclusive), `external_ref`.

### GET /v1/payments/{id}

Scope `payments:read`. Response `200 {"data": Payment}`.

### POST /v1/payments

Scope `payments:write`. Idempotent with `Idempotency-Key`. Records a payment on an order, exactly
like the web app's payment dialog. The order's paid/pending amounts and payment status are
recalculated. Response `201 {"data": Payment}`.

| Field | Rules |
|---|---|
| `order_id` | UUID, **required**; an order the key cannot see → `422` (`order_id: not_found`) |
| `amount` | **required**, > 0, ≤ 10,000,000, 2 decimals |
| `direction` | `in` (default) \| `out` |
| `method` | **required**: `cash`, `credit_card`, `bank_transfer`, `check`, `bit`, `offset`, `other` |
| `paid_at` | date-time with offset; default now |
| `reference` | ≤ 200 |
| `notes` | ≤ 2000 (internal; not returned by the API) |
| `status` | cheques only: `pending`, `deposited`, `cleared` (default `pending` for incoming, `cleared` for refunds). For other methods only `cleared` is accepted (and is the default) |
| `check` | **required for `check`**, not allowed otherwise: `{due_date (YYYY-MM-DD, required), number? (≤50), bank? (≤100), branch? (digits, ≤10), account? (digits, ≤15)}` — non-digits are stripped from branch/account |
| `external_ref` | `{integration, external_id}` |

```json
{ "order_id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d", "amount": 1000, "method": "bank_transfer", "reference": "העברה 7781" }
```

### 15.8 Documents

Quotes, order confirmations, delivery/return notes, invoices and receipts. Read-only in API v1.

#### Document object

`GET /v1/documents` omits `lines` and `payments`.

```json
{
  "id": "d4c3b2a1-0f9e-4d8c-b7a6-958473625140",
  "object": "document",
  "doc_type": "tax_invoice_receipt",
  "status": "issued",
  "doc_number": "30017",
  "order_id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d",
  "customer_id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "references_document_id": null,
  "customer": { "name": "אולמי הגן בע\"מ", "vat_number": "515123453" },
  "currency": "ILS",
  "totals": { "subtotal": 3600, "discount_total": 0, "taxable_amount": 3600, "vat_rate": 0.18, "vat_amount": 648, "grand_total": 4248 },
  "document_date": "2026-10-03",
  "due_date": null,
  "issued_at": "2026-10-03T12:00:00.000Z",
  "notes": null,
  "event_location": "גן האירועים, חיפה",
  "allocation": { "status": "approved", "number": "123456789" },
  "accepted_at": null,
  "cancelled_at": null,
  "lines": [ { "id": "…", "position": 1, "description": "שולחן עגול 1.8", "sku": null, "product_id": "…", "quantity": 10, "unit_price": 120, "discount_percent": 0, "vat_exempt": false, "line_total": 1200 } ],
  "payments": [ { "id": "…", "payment_id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "method": "bank_transfer", "amount": 4248, "paid_at": "2026-10-03T12:00:00.000Z", "reference": null, "check": null } ],
  "created_at": "2026-10-03T11:58:00.000Z",
  "updated_at": "2026-10-03T12:00:05.000Z"
}
```

- `doc_type` ∈ `quote`, `order_confirmation`, `delivery_note`, `return_note`,
  `transaction_invoice`, `proforma`, `tax_invoice`, `tax_invoice_receipt`, `credit_invoice`,
  `receipt`. `status` ∈ `draft`, `issued`, `cancelled`.
- `allocation` is the Israel Tax Authority allocation number state; a freshly issued document may
  still be `pending`.
- Documents have no `external_refs` field in v1 (references can still be created and looked up
  through `/v1/external-refs` and the `external_ref` filter).

### GET /v1/documents

Scope `documents:read`. Common list parameters, plus `doc_type` (comma list), `status` (comma
list), `customer_id`, `order_id`, `date_from`, `date_to` (inclusive, on `document_date`),
`external_ref`.

### GET /v1/documents/{id}

Scope `documents:read`. Response `200 {"data": Document}` with lines and payments.

### 15.9 Suppliers

```json
{
  "id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9",
  "object": "supplier",
  "name": "טקסטיל הצפון",
  "business_id": "514000007",
  "email": "orders@example.co.il",
  "phone": "04-8123456",
  "address": "האומן 5",
  "city": "חיפה",
  "payment_terms": "שוטף + 30",
  "notes": null,
  "is_active": true,
  "contacts": [ { "id": "…", "name": "יוסי", "role": null, "phone": "052-1111111", "email": null, "is_primary": true } ],
  "external_refs": [],
  "created_at": "2025-05-01T10:00:00.000Z",
  "updated_at": "2026-02-11T09:00:00.000Z"
}
```

### GET /v1/suppliers

Scope `suppliers:read`. Common list parameters, plus `search` (≤ 200; name, business id, email,
phone, contact name/phone/email), `is_active`, `external_ref`.

### GET /v1/suppliers/{id}

Scope `suppliers:read`. Response `200 {"data": Supplier}`.

### 15.10 Purchase orders

`GET /v1/purchase-orders` omits `lines`.

```json
{
  "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "object": "purchase_order",
  "po_number": "PO-0031",
  "status": "sent",
  "supplier_id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9",
  "supplier_name": "טקסטיל הצפון",
  "order_date": "2026-09-12",
  "expected_date": "2026-09-20",
  "currency": "ILS",
  "totals": { "subtotal": 2000, "discount_total": 0, "vat_rate": 0.18, "vat_amount": 360, "grand_total": 2360 },
  "supplier_notes": null,
  "sent_at": "2026-09-12T10:00:00.000Z",
  "confirmed_at": null,
  "received_at": null,
  "cancelled_at": null,
  "lines": [ { "id": "…", "position": 1, "description": "מפות לבנות", "product_id": null, "category_id": null, "unit": "יח'", "quantity": 40, "unit_price": 50, "discount_percent": 0, "vat_exempt": false, "line_total": 2000, "received_quantity": 0 } ],
  "external_refs": [],
  "created_at": "2026-09-12T09:40:00.000Z",
  "updated_at": "2026-09-12T10:00:00.000Z"
}
```

`status` ∈ `draft`, `sent`, `confirmed`, `partially_received`, `received`, `cancelled`. Line
quantities may have up to 3 decimals.

### GET /v1/purchase-orders

Scope `purchase_orders:read`. Common list parameters, plus `status` (comma list), `supplier_id`,
`date_from`, `date_to` (inclusive, on `order_date`), `external_ref`.

### GET /v1/purchase-orders/{id}

Scope `purchase_orders:read`. Response `200 {"data": PurchaseOrder}` with lines.

### 15.11 Warehouse tasks

```json
{
  "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
  "object": "warehouse_task",
  "type": "picking",
  "status": "scheduled",
  "priority": "normal",
  "order_id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d",
  "customer_id": "5b1f0c2e-8f7a-4a51-9a7e-2d3c4b5a6f70",
  "customer_name": "אולמי הגן בע\"מ",
  "title": "ליקוט ORD-2026-0412",
  "description": null,
  "scheduled_date": "2026-10-01",
  "scheduled_start": "08:00",
  "estimated_duration_minutes": 60,
  "assigned_worker_name": null,
  "actual_start": null,
  "actual_end": null,
  "total_quantity": 11,
  "completed_quantity": 0,
  "notes": null,
  "created_at": "2026-09-15T08:12:00.000Z",
  "updated_at": "2026-09-15T08:12:00.000Z"
}
```

`type` ∈ `picking`, `return`, `inventory_count`, `maintenance`, `cleaning`, `transfer`, `other`;
`status` ∈ `scheduled`, `ready`, `in_progress`, `paused`, `completed`, `delayed`, `cancelled`.

### GET /v1/warehouse/tasks

Scope `warehouse:read`. Common list parameters (no `external_ref`), plus `status` (comma list),
`type` (comma list), `order_id`, `scheduled_from`, `scheduled_to` (inclusive, on
`scheduled_date`).

### GET /v1/warehouse/tasks/{id}

Scope `warehouse:read`. Response `200 {"data": WarehouseTask}`.

### 15.12 External references

```json
{ "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "object": "external_ref", "integration": "woocommerce", "entity_type": "order",
  "entity_id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d", "external_id": "12345", "created_at": "2026-09-15T08:10:00.000Z", "updated_at": "2026-09-15T08:10:00.000Z" }
```

### GET /v1/external-refs

Scope: the read scope of `entity_type`. No pagination (at most 100 values are looked up); list
envelope with `has_more: false`, `limit: 100`, ordered by creation.

| Parameter | Notes |
|---|---|
| `integration` | **required** slug |
| `entity_type` | **required**: `customer`, `order`, `product`, `payment`, `supplier`, `purchase_order`, `document` |
| `external_id` | comma list (≤ 100 values, each ≤ 255). External ids that contain a comma cannot be looked up here — use the `external_ref` filter of the entity list instead |
| `entity_id` | comma list of UUIDs (≤ 100) |

Exactly one of `external_id` and `entity_id` is required.

### PUT /v1/external-refs

Scope `external_refs:write`. Upsert **by record**: links `entity_id` to `external_id` for the
integration, replacing the record's previous external id for that integration if any. Idempotent
by nature. Response `200 {"data": ExternalRef}`.

```json
{ "integration": "woocommerce", "entity_type": "product", "entity_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d", "external_id": "sku-TBL-180" }
```

- The record must exist and be readable by the key → otherwise `422` (`entity_id: not_found`).
- The external id already links another existing record → `409 external_ref_conflict` with
  `details.existing_entity_id`.

### DELETE /v1/external-refs/{id}

Scope `external_refs:write`. Removes the link (not the record). `204 No Content`; `404` if not
found.

---

## 16. Examples

Set these once:

```bash
export VF_API="https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/api"
export VIPERFLOW_API_KEY="vf_live_…"
```

### curl: list customers

```bash
curl -sS "$VF_API/v1/customers?limit=50&search=%D7%94%D7%92%D7%9F" \
  -H "Authorization: Bearer $VIPERFLOW_API_KEY" \
  -H "x-region: ap-northeast-1"
```

### curl: create a customer

```bash
curl -sS -X POST "$VF_API/v1/customers" \
  -H "Authorization: Bearer $VIPERFLOW_API_KEY" \
  -H "x-region: ap-northeast-1" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f1d6a52-0f9b-4c2e-9d83-5e1a4b7c2f60" \
  -d '{
    "business_name": "אולמי הגן בע\"מ",
    "business_id": "515123453",
    "contacts": [{ "name": "דנה כהן", "phone": "050-1234567" }],
    "external_ref": { "integration": "my-erp", "external_id": "C-881" }
  }'
```

### curl: create an order

```bash
curl -sS -X POST "$VF_API/v1/orders" \
  -H "Authorization: Bearer $VIPERFLOW_API_KEY" \
  -H "x-region: ap-northeast-1" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-import-12345" \
  -H "X-ViperFlow-Integration: woocommerce" \
  -d '{
    "customer_ref": { "integration": "my-erp", "external_id": "C-881" },
    "event_date": "2026-10-02",
    "delivery_date": "2026-10-01T14:00:00+03:00",
    "return_date": "2026-10-03T10:00:00+03:00",
    "items": [{ "product_id": "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d", "quantity": 10 }],
    "logistics": { "truck": { "quantity": 1 } },
    "external_ref": { "integration": "woocommerce", "external_id": "12345" }
  }'
```

### curl: fetch orders changed since a point in time

```bash
curl -sS "$VF_API/v1/orders?sort=updated_at&limit=100&status=confirmed,picked&updated_since=2026-09-14T00:00:00Z" \
  -H "Authorization: Bearer $VIPERFLOW_API_KEY" \
  -H "x-region: ap-northeast-1"
```

### curl: confirm an order

```bash
curl -sS -X POST "$VF_API/v1/orders/a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d/confirm" \
  -H "Authorization: Bearer $VIPERFLOW_API_KEY" \
  -H "x-region: ap-northeast-1"
```

### TypeScript: fetch all orders with pagination

Node 18+ (global `fetch`), no dependencies.

```ts
const BASE_URL = "https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/api";
const API_KEY = process.env.VIPERFLOW_API_KEY!;

interface Page<T> {
  data: T[];
  pagination: { limit: number; has_more: boolean; next_cursor: string | null };
}

class ViperFlowError extends Error {
  constructor(public status: number, public code: string, message: string, public requestId?: string) {
    super(message);
  }
}

async function vf<T>(path: string, init: RequestInit = {}): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`${BASE_URL}${path}`, {
      ...init,
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "x-region": "ap-northeast-1",
        ...(init.body ? { "Content-Type": "application/json" } : {}),
        ...init.headers,
      },
    });
    if (res.status === 204) return undefined as T;
    const body = await res.json();
    if (res.ok) return body as T;

    const retryable = res.status === 429 || res.status === 409 || res.status >= 500;
    if (retryable && attempt < 5) {
      const retryAfter = Number(res.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt;
      await new Promise((r) => setTimeout(r, delayMs));
      continue;
    }
    throw new ViperFlowError(res.status, body.error?.code, body.error?.message, body.error?.request_id);
  }
}

export async function* listOrders(filters: Record<string, string> = {}) {
  let cursor: string | null = null;
  do {
    const params = new URLSearchParams({ limit: "100", sort: "updated_at", ...filters });
    if (cursor) params.set("cursor", cursor);
    const page: Page<{ id: string; order_number: string; updated_at: string }> = await vf(`/v1/orders?${params}`);
    yield* page.data;
    cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
  } while (cursor);
}

// URLSearchParams encodes "+" in offsets correctly.
for await (const order of listOrders({ updated_since: "2026-09-14T00:00:00+03:00" })) {
  console.log(order.order_number, order.updated_at);
}
```

### TypeScript: create an order with an Idempotency-Key

```ts
import { randomUUID } from "node:crypto";

export async function createOrder(input: {
  customerId: string;
  eventDate: string;          // "2026-10-02"
  deliveryDate: string;       // "2026-10-01T14:00:00+03:00"
  returnDate: string;         // "2026-10-03T10:00:00+03:00"
  items: { productId: string; quantity: number; options?: string[] }[];
  shopOrderId: string;
}) {
  // Persist this key with the job BEFORE the first attempt, so every retry reuses it.
  const idempotencyKey = randomUUID();
  const body = JSON.stringify({
    customer_id: input.customerId,
    event_date: input.eventDate,
    delivery_date: input.deliveryDate,
    return_date: input.returnDate,
    items: input.items.map((i) => ({ product_id: i.productId, quantity: i.quantity, options: i.options })),
    external_ref: { integration: "woocommerce", external_id: input.shopOrderId },
  });

  try {
    const { data } = await vf<{ data: { id: string; order_number: string; totals: { grand_total: number } } }>(
      "/v1/orders",
      { method: "POST", body, headers: { "Idempotency-Key": idempotencyKey, "X-ViperFlow-Integration": "woocommerce" } },
    );
    return data;
  } catch (err) {
    if (err instanceof ViperFlowError && err.code === "external_ref_conflict") {
      // Already imported earlier (possibly days ago): fetch the existing order instead.
      const page = await vf<Page<{ id: string }>>(
        `/v1/orders?external_ref=${encodeURIComponent(`woocommerce:${input.shopOrderId}`)}`,
      );
      return page.data[0];
    }
    throw err;
  }
}
```

The `vf` helper retries `409`/`429`/`5xx` with the same body and headers — and therefore the same
`Idempotency-Key` — so a retried create never produces a second order.

Webhook signature verification examples (TypeScript, Python, PHP) are in
[`WEBHOOKS.md`](WEBHOOKS.md#verifying-signatures).

## 17. Versioning

- The major version is in the path (`/v1/`) and in the `X-ViperFlow-Api-Version` response header.
- Within v1 ViperFlow may add endpoints, optional request fields, response fields, query
  parameters, enum values (statuses, document types, line types), error codes and webhook event
  types. Build clients that ignore unknown fields and handle unknown enum values gracefully.
- Removing or renaming fields, changing types or semantics, or making optional input required is
  a breaking change and ships only as a new major version, announced in advance, with v1 kept
  running for a transition period.

## 18. Security notes

- Keep keys in a secret manager or server-side environment variables; never in source control,
  front-end code, mobile apps, logs or URLs.
- Grant the narrowest scopes, use one key per integration, and prefer expiring keys for
  temporary work. Test keys still read live data.
- Revoke a key immediately if it may have leaked (Settings › Developers › API keys). The request
  log (14 days) shows each key's recent calls with their request ids.
- ViperFlow stores only a SHA-256 hash of each key. The request log keeps metadata only —
  method, path **without the query string**, route template, status, error code, duration,
  client IP, `User-Agent` (200 characters), the `Idempotency-Key` value and the dry-run flag.
  Request bodies, query strings, response bodies, the `Authorization` header and key material
  are never stored. Do not put secrets of your own into an `Idempotency-Key`.
- Webhook receivers must verify signatures ([`WEBHOOKS.md`](WEBHOOKS.md)).

## 19. Known limitations

- **No sandbox:** test keys read live data and cannot persist writes ([§4](#4-environments-and-test-mode)).
- **Deletions** are not exposed by list endpoints; they are observable only through `*.deleted`
  webhooks.
- **Order item ids** change when an order is edited in the web app.
- **Orders:** no item, price or payment-term changes after creation; no delete; availability is
  not checked; high discounts cannot be approved by API keys; there is no `completed` status.
- **Inventory** quantities exclude date-based reservations.
- **Read-only resources:** products, categories, inventory, documents, suppliers, purchase orders
  and warehouse tasks cannot be written in v1. Customers cannot be deleted; contacts cannot be
  deleted.
- **Messages** of database business-rule errors may be in Hebrew.
- **External ids containing commas** cannot be looked up through `GET /v1/external-refs`.
- **Categories** are returned in one page of at most 1000.
- **Rate limiting by IP** is best effort.
