oToK Developer Docs

Orders

E-commerce-grade orders attached to your workspace's contacts. An order carries line items, header money rollups, an append-only refund ledger, and a two-axis status model: financial_status (the money) and fulfillment_status (the goods), plus a separate cancellation stamp that touches neither axis. Order writes fire the workspace's order automations and registered order.* webhooks exactly as in-app writes do.

All endpoints require authentication. Orders cannot be deleted via the API, and there are no PATCH routes — after creation, status changes ride the action routes below, and field updates ride the idempotent re-POST (see upsert semantics).

Plan feature required: every route on this page requires the Orders feature on the workspace's plan, in addition to API access. Without it, all calls return 403 with error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" — see feature-gated resource groups. The Orders feature is included on all current plans, so in practice this error appears only when the feature has been switched off for a specific workspace.

Method Path Purpose
GET /api/v1/orders List orders (filterable, paginated)
GET /api/v1/orders/:id Get one order with line items + refunds
POST /api/v1/orders Create an order (idempotent upsert via external_reference)
POST /api/v1/orders/:id/refunds Record a refund (idempotent via external_refund_id)
POST /api/v1/orders/:id/mark-paid Mark an order paid
POST /api/v1/orders/:id/cancel Cancel an order

All POSTs return 201, including the action routes. Every write response returns the full order object — header, items, refunds, and joined contact identity (list rows omit items/refunds); the refunds route wraps it as { duplicate, order }.

The order object

Money is numeric. Order money fields (total, subtotal, unit_price, refund amount, …) serialize as JSON numbers, rounded to 2 decimals, in the order's charge currency — the same JSON-number serialization used by deals and payments.

Header fields:

Field Type Notes
id UUID
workspace_id UUID
contact_id UUID The order's contact (required, always set)
contact_name / contact_phone / contact_email string or null Joined contact identity — included on list rows and detail/write responses
order_number integer Per-workspace sequential number, assigned at create
number string or null Store-side display number (e.g. "#2043") — null for API- and app-created orders (see store provenance)
platform string Origin: api for API-created orders, manual for in-app, automation for automation-created; store platform names are reserved for store-synced orders
source string Same vocabulary as platform for non-store orders
store_connection_id UUID or null Store provenance — null for API- and app-created orders
store_domain string or null Store provenance — null for API- and app-created orders
external_order_id string or null Store-side order id — null for API- and app-created orders
external_reference string or null Your idempotency key, unique per workspace
deal_id UUID or null Optional link to a deal of the same contact
financial_status enum pending, paid, partially_paid, refunded, partially_refunded, voided. partially_paid appears on reads (set in-app) but is not reachable through API writes
fulfillment_status enum unfulfilled, partially_fulfilled, fulfilled — read-only via the API (recorded in-app or by store sync; no /v1 route sets it)
currency string 3-letter uppercase; defaults to the workspace currency
total number Header money — see money math
subtotal number Sum of rounded line totals
discount_total number Document-level discount
shipping_total number
tax_total number
refunded_total number Rollup of the refund ledger
item_count integer Quantity sum, rounded to the nearest integer
first_item_name string or null
coupon_codes string[] Applied discount/coupon codes
placed_at ISO 8601 Order time (defaults to creation time)
paid_at ISO 8601 or null Stamped on first entry into a paid state; historical — kept through later refund states
cancelled_at ISO 8601 or null The cancellation stamp — cancellation is not a financial status
refunded_at ISO 8601 or null Last refund instant
external_updated_at ISO 8601 or null Store-sync snapshot clock — null for API- and app-created orders
payment_reference string or null Reference of the recorded payment backing this order (see mark-paid)
payment_synced_at ISO 8601 or null Payment-recording convergence stamp (informational)
sale_id UUID or null The sale this order belongs to — the one named with sale_id, or the sale recorded when the order was paid
note string or null
metadata object or null Read-only via the API — not settable on any /v1 route
created_by UUID or null null for API writes
created_at / updated_at ISO 8601

Line item (items[], detail/write responses only, ordered by position):

Field Type Notes
id, workspace_id, order_id UUID
position integer 0-based
product_id UUID or null Soft catalog link — null when no product resolved; survives product deletion as null
external_product_id string or null Store-side product id — null for API-created lines
title string Denormalized snapshot
sku string or null Denormalized snapshot
quantity number Decimal quantities allowed (weight/hours); default 1
unit_price number In the order currency
discount_percent number or null Percent-only per-line discount, 0–100
line_total number Server-computed — see money math
created_at ISO 8601

Refund (refunds[], detail/write responses only, ordered by refunded_at ascending):

Field Type Notes
id, workspace_id, order_id UUID
external_refund_id string or null Caller idempotency key; null for keyless refunds
amount number Positive, in the order currency
currency string The order's currency
reason string or null
refunded_at ISO 8601 Defaults to record time
created_at ISO 8601

Money math

Totals are computed server-side and are authoritative — client-sent totals are never trusted:

Rounding is 2-decimal, half-up.

Store provenance fields

number, store_connection_id, store_domain, external_order_id, and external_updated_at are populated only for orders synced from a connected e-commerce store (where store sync is available on the workspace) — for every order you create via the API or in the app they are always null. Store-synced orders are read-only through this API: the mutation routes reject them with 409 error_code: "STORE_SYNCED_READ_ONLY". Orders created via the API can never trigger that error.

GET /api/v1/orders

This route uses dedicated query parameters (not the generic filter) with the deals/payments pagination family (see where deals and payments differ) — but note the clamping difference on limit/offset below the table. There is no search parameter on this route.

Param Type Notes
status enum Financial status: pending, paid, partially_paid, refunded, partially_refunded, voided. Any other value is silently ignored (unfiltered result)
contact_id UUID Exact match. Malformed → 400 "contact_id must be a UUID"; empty (?contact_id=) is treated as absent
source string Exact match — manual, api, automation (store platform values are reserved for store-synced orders)
store_connection_id UUID Exact match. Malformed → 400 "store_connection_id must be a UUID". Matches only store-synced orders — nothing for API/app-created orders
external_reference string Exact match — look up an order by your idempotency reference
placed_from ISO 8601 Orders placed at/after. Unparseable → 400 "placed_from is not a valid date"
placed_to ISO 8601 Orders placed at/before. Unparseable → 400 "placed_to is not a valid date"
limit integer Default 25, cap 100, floor 1 — see below
offset integer Default 0, floor 0 — see below

Pagination clamping: unlike deals and payments (which return 400 on malformed paging), orders never rejects limit/offset values — a malformed or zero limit silently defaults to 25 and out-of-range values are clamped into 1–100; a malformed or negative offset silently defaults to 0.

Results are ordered by placed_at descending (not created_at).

curl -G "https://app.otok.io/api/v1/orders" \
  -H "Authorization: Bearer otok_live_abc123..." \
  --data-urlencode 'status=paid' \
  --data-urlencode 'placed_from=2026-07-01'

Response 200 — { data, total, limit, offset }; each row is the order object above with joined contact fields, without items/refunds.

Status Meaning
400 Malformed UUID query param ("contact_id must be a UUID", …) or unparseable placed_from/placed_to
403 error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" — plan lacks the Orders feature

GET /api/v1/orders/:id

The full order with items[] + refunds[] + joined contact identity.

POST /api/v1/orders

Creates an order — or, when external_reference matches an existing order, updates its mutable fields (idempotent upsert; see below). Response 201 in both cases with the full order object (items + refunds + contact join).

No duplicate flag. Unlike POST /v1/contacts / /v1/deals / /v1/payments / /v1/bookings, the order create response carries no top-level duplicate boolean — a fresh create and an external_reference match return the same full-order body. To distinguish the two, compare the response's created_at against your request time, or pre-check with GET /v1/orders?external_reference=….

Request body

Field Type Required Constraints
contact_id UUID one of contact_id OR phone/email Existing contact (404 "Contact not found" if not in this workspace)
phone string ″ ≤32 chars; a matching contact is used, or one is created
email string ″ Valid email; same upsert semantics
name string no ≤200 — used only when a new contact is created
items array no ≤200 items (see item shape)
currency string no ≤8 chars, uppercased. Omitted → workspace currency
discount_total number no ≥0 (document-level discount)
shipping_total number no ≥0
tax_total number no ≥0
financial_status enum no pending (default) or paid only — a paid create records the payment and fires order-paid automations. Never applied on an external_reference match
placed_at string no ISO 8601; defaults to now
coupon_codes string[] no ≤50 entries
note string no ≤5000
deal_id UUID no Must be a deal of the same contact — 404 ORDER_DEAL_NOT_FOUND when unknown to the workspace, 409 ORDER_DEAL_CONTACT_MISMATCH when it belongs to another contact
sale_id UUID no An existing sale this order belongs to — see orders and sales. A create-time link only: an external_reference match never re-points it
external_reference string no ≤255 — the idempotency key, unique per workspace

Orders and sales

When an order is paid (a paid create, or mark-paid), oToK records the payment and — unless the workspace switched it off in its Sales settings — also records the order as a sale, and stamps the order's sale_id. Pass sale_id (on create or on mark-paid) to have the payment fund an existing sale instead of recording a new one. The sale must belong to the order's contact, be in the order's currency and not be cancelled, and the workspace's plan must include Sales.

Item shape

Field Type Required Constraints
product_id UUID no Explicit catalog product — strict: unresolvable → 400 INVALID_PRODUCT; inactive → 400 PRODUCT_INACTIVE
product_sku string no ≤120 — resolve by SKU. Tolerant: no match → the line keeps its literal title with product_id: null; an inactive match still rejects (PRODUCT_INACTIVE)
product_external_id string no ≤255 — resolve by your external product id; same tolerant semantics as SKU
title string required unless a product resolves ≤300 — when a product resolves, the line title derives from the product name (any client-sent title is ignored); no product and no title → 400 "Order items require a title or product_id"
sku string no ≤120 — denormalized SKU snapshot on the line (falls back to product_sku)
unit_price number no 0 – 9,999,999,999. Omitted with a priced product → the product's price. Omitted with a dynamic-priced product (no catalog price) → 400 ORDER_ITEM_PRICE_REQUIRED. Omitted with no product → 0
quantity number no Positive; decimals allowed; default 1
discount_percent number no 0–100 (percent-only per-line discount)

A product reference is resolved in order: product_id → product_sku → product_external_id (first provided wins).

Contact resolution

Shared with the contacts upsert (same as deals and payments):

Idempotent upsert via external_reference

external_reference is unique per workspace. A repeat POST with a matching value updates that order instead of creating a duplicate — this is race-safe: a concurrent create that loses the uniqueness race is automatically retried as an update against the winner.

A direct-conflict 409 (error_code: "ORDER_REFERENCE_EXISTS", message "An order with this external_reference already exists.") exists in the contract but is resolved automatically by the retry-as-update path — callers should not normally observe it.

Example

curl -X POST "https://app.otok.io/api/v1/orders" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+972501234567",
    "email": "dana@example.com",
    "name": "Dana Levi",
    "currency": "ILS",
    "shipping_total": 20,
    "items": [
      { "product_sku": "WGT-1", "quantity": 2, "unit_price": 120 },
      { "title": "Gift wrap", "unit_price": 100 }
    ],
    "coupon_codes": ["SUMMER10"],
    "external_reference": "shop-order-1042"
  }'

Response 201:

{
  "id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "workspace_id": "…",
  "contact_id": "9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c",
  "contact_name": "Dana Levi",
  "contact_phone": "+972501234567",
  "contact_email": "dana@example.com",
  "order_number": 1042,
  "number": null,
  "platform": "api",
  "source": "api",
  "store_connection_id": null,
  "store_domain": null,
  "external_order_id": null,
  "external_reference": "shop-order-1042",
  "deal_id": null,
  "financial_status": "pending",
  "fulfillment_status": "unfulfilled",
  "currency": "ILS",
  "total": 360,
  "subtotal": 340,
  "discount_total": 0,
  "shipping_total": 20,
  "tax_total": 0,
  "refunded_total": 0,
  "item_count": 3,
  "first_item_name": "Widget",
  "coupon_codes": ["SUMMER10"],
  "placed_at": "2026-07-14T10:00:00.000Z",
  "paid_at": null,
  "cancelled_at": null,
  "refunded_at": null,
  "external_updated_at": null,
  "payment_reference": null,
  "payment_synced_at": null,
  "note": null,
  "metadata": null,
  "created_by": null,
  "created_at": "2026-07-14T10:00:00.000Z",
  "updated_at": "2026-07-14T10:00:00.000Z",
  "items": [
    {
      "id": "11a2b3c4-…",
      "workspace_id": "…",
      "order_id": "0a1b2c3d-…",
      "position": 0,
      "product_id": "77e8f9a0-…",
      "external_product_id": null,
      "title": "Widget",
      "sku": "WGT-1",
      "quantity": 2,
      "unit_price": 120,
      "discount_percent": null,
      "line_total": 240,
      "created_at": "2026-07-14T10:00:00.000Z"
    },
    {
      "id": "22b3c4d5-…",
      "workspace_id": "…",
      "order_id": "0a1b2c3d-…",
      "position": 1,
      "product_id": null,
      "external_product_id": null,
      "title": "Gift wrap",
      "sku": null,
      "quantity": 1,
      "unit_price": 100,
      "discount_percent": null,
      "line_total": 100,
      "created_at": "2026-07-14T10:00:00.000Z"
    }
  ],
  "refunds": []
}

Errors

Status Code / message Meaning
400 validation array Body violations — unknown properties rejected; length/range/enum/ISO-8601 checks per the tables above
400 "Provide contact_id, or a phone/email…" No contact reference
400 "Order items require a title or product_id" Line with neither
400 INVALID_PRODUCT / PRODUCT_INACTIVE Explicit product_id unresolvable / product inactive (an inactive SKU / external-id match also rejects)
400 ORDER_ITEM_PRICE_REQUIRED Dynamic-priced product without an explicit unit_price
400 "placed_at is not a valid date" The value passed ISO-8601 syntax but is not a real date
400 "discount_total must be a non-negative number" (etc.) Money-field checks
403 FEATURE_NOT_INCLUDED_IN_PLAN Plan lacks the Orders feature (body has no statusCode field)
403 PLAN_LIMIT_EXCEEDED "Plan limit reached for max_orders (N)…" — orders are uncapped by default on all plans; this appears only when a cap has been set on the workspace
404 "Contact not found" contact_id not in this workspace
404 ORDER_DEAL_NOT_FOUND deal_id unknown to the workspace
409 ORDER_DEAL_CONTACT_MISMATCH deal_id belongs to another contact
400 CURRENCY_MISMATCH sale_id names a sale in another currency
404 "Sale not found" sale_id unknown to the workspace
409 PLAN_FEATURE_REQUIRED sale_id sent while the plan has no Sales feature
409 SALE_ALREADY_CANCELLED sale_id names a cancelled sale
409 ORDER_SALE_CONTACT_MISMATCH sale_id belongs to another contact
409 ORDER_SALE_ALREADY_LINKED The order already belongs to a different sale
409 CONTACT_MERGE_REQUIRED Phone and email resolve to two different contacts
409 ORDER_REFERENCE_EXISTS Reference collision — normally resolved automatically as an update; not typically observable

(error_code values appear as an error_code field on the response body.)

Side effects

Creation fires the workspace's order-created automations (plus order-paid when created as paid), writes the contact's activity timeline, and emits registered order.created (and order.paid) webhooks. A paid create also records a completed payment for the full order total, in the order's currency, on the contact's payment history, and a sale (see orders and sales). API writes are attributed to source api with no acting user.

POST /api/v1/orders/:id/refunds

Appends to the order's refund ledger, rolls the financial status to partially_refunded/refunded, mirrors the refund into the recorded payment, and fires the order-refunded automations + the order.refunded webhook (and, for the mirrored payment refund, the payment.refunded webhook).

This route records a refund — it never moves money at a payment gateway. Requires an API key with refund access (403 API_KEY_MONEY_OUT_DISABLED otherwise). When the order's payment was collected through the connected payment provider, refund it from the payment instead (POST /v1/payments/:id/refund, which can refund at the gateway or record the refund as returned outside oToK) — this route answers 409 REFUND_VIA_PROVIDER_REQUIRED with the payment_id to use; the order's refund ledger follows that payment refund automatically.

Request body

Field Type Required Constraints
amount number yes Positive, in the order's currency; must not exceed the remaining total (total − refunded_total)
external_refund_id string no ≤255 — the idempotency key: a repeat POST with the same value applies nothing and returns duplicate: true. Without it, refunds are NOT idempotent — every POST appends a new refund. Supply it whenever your system can retry
reason string no ≤1000
refunded_at string no ISO 8601; defaults to now

The example continues the order created above, assuming it was first marked paid — refunding a never-paid order returns 400 ORDER_NEVER_PAID.

curl -X POST "https://app.otok.io/api/v1/orders/0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9/refunds" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "amount": 50, "external_refund_id": "r-1", "reason": "damaged" }'

Response 201

{
  "duplicate": false,
  "order": {
    "id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "financial_status": "partially_refunded",
    "refunded_total": 50,
    "refunded_at": "2026-07-15T09:30:00.000Z",
    "…": "…full order object with items + refunds…",
    "refunds": [
      {
        "id": "33c4d5e6-…",
        "workspace_id": "…",
        "order_id": "0a1b2c3d-…",
        "external_refund_id": "r-1",
        "amount": 50,
        "currency": "ILS",
        "reason": "damaged",
        "refunded_at": "2026-07-15T09:30:00.000Z",
        "created_at": "2026-07-15T09:30:00.000Z"
      }
    ]
  }
}

duplicate: true means the external_refund_id was already recorded on this order — nothing was applied and the current order state is returned.

Semantics

Errors

Status Code / message
400 ORDER_NEVER_PAID; "Refund amount must be a positive number"; "Refund amount exceeds the order's remaining total (…)"; "refundedAt is not a valid date"; validation array
403 FEATURE_NOT_INCLUDED_IN_PLAN
403 API_KEY_MONEY_OUT_DISABLED — the API key lacks refund access
404 "Order not found"
409 ORDER_NOT_CAPTURED — the order is partially_paid or voided
409 REFUND_VIA_PROVIDER_REQUIRED — the order's payment was collected through the payment provider; refund the payment named by payment_id
409 REFUND_IN_STORE — the order's payment came from a connected store; refund it in the store
409 STORE_SYNCED_READ_ONLY — store-synced orders only

POST /api/v1/orders/:id/mark-paid

Moves the financial status to paid, records the payment (a completed one-time payment for the full order total, in the order's currency, on the contact's payment history) — or links onto an existing payment via payment_reference — records the sale (see orders and sales), and fires the order-paid automations + the order.paid webhook.

Request body (all optional)

Field Type Notes
payment_reference string ≤255 — the external_reference of an existing payment (e.g. one your system already recorded via POST /v1/payments) to link the order onto instead of recording a new payment. Link-only — the payment's amount is never rewritten
sale_id UUID An existing sale this payment funds — no new sale is recorded. Same checks as on create
curl -X POST "https://app.otok.io/api/v1/orders/0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9/mark-paid" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "payment_reference": "shop-charge-1042" }'

Response 201 — the full order object.

Transition rules

From mark-paid allowed?
pending yes
partially_paid yes
paid no-op success — 201, no side effects, no duplicate automations
refunded / partially_refunded / voided 409 error_code: "ORDER_ILLEGAL_TRANSITION", message "Illegal status transition <from> → paid. Refund states are set by recording refunds."

paid_at is stamped on first entry into a paid state and is historical — it is kept through later refund states.

Errors

The ORDER_PAYMENT_* codes validate payment_reference and are checked before linking:

Status Code / message Meaning
403 FEATURE_NOT_INCLUDED_IN_PLAN Plan lacks the Orders feature
404 "Order not found" Unknown id or another workspace's order
404 ORDER_PAYMENT_REFERENCE_NOT_FOUND No payment in this workspace matches the reference
409 ORDER_ILLEGAL_TRANSITION Refund states / voided — see the transition rules above
409 ORDER_PAYMENT_CONTACT_MISMATCH The payment belongs to another contact
409 ORDER_PAYMENT_NOT_LINKABLE Not a one-time payment with a completed charge
409 ORDER_PAYMENT_ALREADY_LINKED The order is already linked to a different payment reference (re-sending the same reference is fine)
400 / 404 / 409 CURRENCY_MISMATCH / "Sale not found" / PLAN_FEATURE_REQUIRED, SALE_ALREADY_CANCELLED, ORDER_SALE_CONTACT_MISMATCH, ORDER_SALE_ALREADY_LINKED sale_id checks — as on create
409 STORE_SYNCED_READ_ONLY Store-synced orders only

POST /api/v1/orders/:id/cancel

Stamps cancelled_at and fires the order-cancelled automations + the order.cancelled webhook. No request body.

curl -X POST "https://app.otok.io/api/v1/orders/0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9/cancel" \
  -H "Authorization: Bearer otok_live_abc123..."

Response 201 — the full order object.

Semantics:

Status Meaning
403 FEATURE_NOT_INCLUDED_IN_PLAN
404 "Order not found"
409 STORE_SYNCED_READ_ONLY — store-synced orders only