SBCoreX · Integration Guide

Connect to the
SmartBamboo POS API

A single GraphQL endpoint for products, customers, orders, and payments — the same data the point-of-sale runs on. This guide gets you authenticated and reading the schema in a few minutes.

Protocol  GraphQL over HTTPS Transport  AWS AppSync Method  POST Encoding  application/json
POST /graphql
# every call is a POST with a query body
{ "query": "{ ping }" }
200 response
{
  "data": {
    "ping": "pong"
  }
}
01

Connect

The API lives behind one GraphQL endpoint. We hand you three values at onboarding — keep the key secret and out of client-side code.

ValueShapeWhat it's for
Endpoint URLhttps://<id>.appsync-api.<region>.amazonaws.com/graphqlWhere every request is POSTed.
API keyda2-… (AWS AppSync format)Opens the public endpoints (login, ping). Sent as x-api-key.
Regionus-east-2The AWS region the endpoint runs in.
serverUrlhttps://dev.odoo-smartbamboo.mx (test)Passed to login — identifies the backing ERP for your environment.
databasesmart-bamboo-dev (test)Passed to login alongside serverUrl.
EnvironmentsTest against the dev endpoint first — it exposes the same schema as production but must be paired with the test serverUrl and database above. Do not point a test integration at the production values: the endpoint will accept them and you will be reading (and writing) live data. You get the production URL, key, and login parameters once integration is signed off.
02

Authenticate

Authentication is two-tier. The API key gets you to the public endpoints; logging in returns a short-lived JWT that unlocks everything else. Pick the header that matches what you're calling.

Primary

JWT token

Sent on every protected query and mutation. Obtained from login. Send the raw JWT — no Bearer prefix.

Authorization: <jwt>
Public

API key

For the open endpoints — login, ping, isTokenValid.

x-api-key: <key>
Internal

AWS IAM

SigV4-signed, reserved for our own service-to-service webhooks.

Authorization: AWS4-HMAC-…

Step 1 — Exchange credentials for a token

Call login with the API key in the header. It returns a JWT valid for roughly 24 hours, plus the signed-in user and company.

POST /graphql · x-api-key
mutation Login {
  login(
    serverUrl: "https://dev.odoo-smartbamboo.mx"  # test env
    database:  "smart-bamboo-dev"                # test env
    username:  "partner@example.com"
    password:  "••••••••"
  ) {
    token           # put this in the Authorization header
    user {
      id
      name
      email
      company { id name }
    }
  }
}

Step 2 — Send the token on protected calls

From here on, set the Authorization header to the JWT (raw, no Bearer prefix). The API key is no longer needed for these requests.

Step 3 — Refresh before it expires

Call refreshToken with a still-valid token to get a fresh one — no need to re-enter credentials.

POST /graphql · Authorization
mutation Refresh {
  refreshToken { token }
}
03

Make a request

Every call — query or mutation — is a single POST with a JSON body containing query and optional variables. Here's a complete authenticated request over curl.

POST curl
curl -X POST "$ENDPOINT" \
  -H "Authorization: $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Products($pl: ID!) { getProducts(pricelistId: $pl) { product { id name basePrice } pricedVariants { variantId price } } }",
    "variables": { "pl": "<your pricelistId>" }
  }'

A successful response wraps results under data, keyed by the operation name:

200 response
{
  "data": {
    "getProducts": [
      {
        "product": { "id": "5659", "name": "Almohada Memory", "basePrice": 499.00 },
        "pricedVariants": [ { "variantId": "7210", "price": 449.00 } ]
      }
    ]
  }
}
04

Explore live

Run real queries against the endpoint right here. Open the Headers tab in the explorer and add x-api-key (for public operations) or Authorization (for everything else), then hit run. The full schema loads by introspection once your headers are accepted.

Loading the GraphQL explorer… if this doesn't appear, your network may block the Apollo embed. Use the curl example above instead.

Powered by Apollo's embedded Explorer. Nothing you type here is stored on this page.

05

Schema

The API exposes queries for reading and mutations for writing — roughly 98 of each. Below are the operations most external integrations use, with their exact signatures. The full schema is browsable by introspection in the explorer above (works with x-api-key alone), and the SDL is available on request.

Queries — read data

Query
getProducts(pricelistId: ID!, categoryId: ID, posConfigId: ID): [ProductForSale!]!
The catalog priced against a pricelist. Each entry is product + pricedVariants.
Query
getProductCategories(posConfigId: ID): [ProductCategory!]!
The category tree used to group products.
Query
getPricelists(ids: [ID!]): [Pricelist!]!
Pricelists visible to your user.
Query
getPricelistVersions(ids: [ID!]): [PricelistVersion!]!
lastModified per pricelist — poll it to detect price changes.
Query
searchPartners(query: String!): [User!]!
Look up customers by name. Returns the lightweight User shape (no phone/vat).
Query
getOrderById(orderId: ID!): PosOrderDetail
A single order with lines, payments, and totals. orderId is the Odoo id — pass Order.odooOrderId, not Order.id (the order-<uuid> from searchOrders is rejected). The receipt reference is posReference.
Query
searchOrders(query: String!, sessionId: ID, limit: Int): [Order!]!
Search orders by reference or customer text.
Query
getCustomerHistory(partnerId: ID!, limit: Int, offset: Int): [OrderSummary!]!
A customer's purchase history, paginated.
Query
getPaymentMethods(posId: ID!): [PaymentMethod!]!
Payment methods available to a point of sale.
Query
getQuotation(quotationId: ID!): Quotation
Retrieve a saved quotation / wishlist.
Query
getHomeDeliveries(posConfigId: ID!, automaticPurchaseSale: Boolean!, filter: HomeDeliveryFilter, offset: Int, limit: Int): HomeDeliveryPage!
Delivery orders and their fulfillment status, paginated.

Mutations — write data

Mutation
login(serverUrl: String!, database: String!, username: String!, password: String!): AuthPayload!
Authenticate and receive a JWT (token + user).
Mutation
refreshToken: RefreshTokenPayload!
Renew a valid token.
Mutation
createPartner(input: CreatePartnerInput!): Partner!
Register a new customer. Returns the full Partner (phone, vat, address).
Mutation
updatePartner(id: ID!, input: UpdatePartnerInput!): Partner!
Edit an existing customer.
Mutation
createOrderFromCart(cartId: ID!, tabNumber: Int, posSessionId: ID, loginNumber: Int): Order!
Turn a built-up cart into an order.
Mutation
saveWishlist(input: SaveWishlistInput!): Quotation!
Save a quotation for later checkout.
NoteCart-building (createCart, addItemToCart, …) and terminal payments exist too, but they're tuned for the live POS session. Talk to us before driving the checkout flow externally so we can scope the right operations.
06

Core types

The shapes you'll read most often, exactly as the schema defines them — request only what you need; GraphQL returns exactly the fields you select.

type catalog — ProductForSale · Product · ProductVariant
type ProductForSale {        # what getProducts returns
  product: Product!
  pricedVariants: [PricedVariant!]!  # price per variant on the requested pricelist
}

type Product {
  id: ID!
  name: String!
  imageUrl: String
  basePrice: Float!
  productType: String
  category: ProductCategory!
  posCategory: ProductCategory
  attributeOptions: [ProductAttribute!]!
  variants: [ProductVariant!]!
}

type ProductVariant {
  id: ID!                  # the sellable unit — use this id everywhere
  basePrice: Float!
  imageUrl: String
  selectedAttributes: [SelectedAttribute!]!
}

type PricedVariant { variantId: ID!, price: Float! }
type Pricelist { id: ID!, name: String! }
type PricelistVersion { pricelistId: ID!, lastModified: String }
Heads-upSKU (defaultCode) and barcode are not exposed on the catalog types yet. Map products by ProductVariant.id for now — it's stable and unique per sellable unit. Ask us if your integration needs SKU/barcode; it's on the roadmap.
type people — User · Partner
type User {                  # what searchPartners returns
  id: ID!
  name: String!
  username: String!
  email: String!
  company: CompanyInfo!     # { id, name }
}

type Partner {               # returned by createPartner / updatePartner
  id: ID!
  name: String!
  email: String
  phone: String
  mobile: String
  vat: String              # tax id (RFC)
  street: String
  city: String
  zip: String
}
type orders — Order · PosOrderDetail · lines
type Order {                 # what searchOrders returns
  id: ID!                   # internal id, format order-<uuid>
  odooOrderId: Int          # Odoo id — pass THIS to getOrderById (null until synced)
  name: String!
  posReference: String      # receipt reference (not "reference")
  customerId: ID
  customerName: String
  dateOrder: String!
  state: OrderState!        # enum
  amountTotal: Float!
  amountTax: Float!
  amountPaid: Float!
  lines: [OrderLine!]!
  payments: [OrderPayment!]!
}

type OrderLine {
  id: ID!
  productId: ID!
  productName: String!
  variantId: ID
  quantity: Float!
  unitPrice: Float!
  discount: Float
  taxAmount: Float!
  priceSubtotal: Float!    # before tax
  priceTotal: Float!       # tax-inclusive
}

type PosOrderDetail {       # what getOrderById returns
  id: ID!
  name: String!
  posReference: String
  receiptNumber: String
  customerId: ID
  customerName: String
  dateOrder: String
  datePaid: String
  state: String
  amountSubtotal: Float
  amountTax: Float
  amountTotal: Float
  amountPaid: Float
  lines: [PosOrderLine!]   # id, productId, productName, quantity,
  payments: [PosOrderPayment!] # unitPrice, discount, priceSubtotal, priceTotal
}
Order idsAn order carries two ids. Order.id (order-<uuid>) is our internal id and the one searchOrders returns; Order.odooOrderId is the Odoo id. getOrderById(orderId) expects the Odoo id — pass odooOrderId as a string (e.g. "275190"), not the order-<uuid>. Heads-up: odooOrderId is null until the order syncs to Odoo, so a just-created order can't be fetched by getOrderById until it syncs.

Field names follow GraphQL camelCase. Ids are GraphQL ID (strings on the wire) that carry the ERP integer ids. Money is decimal Float; priceSubtotal is pre-tax and priceTotal is tax-inclusive.

07

Errors

The HTTP status is almost always 200 — GraphQL reports problems in an errors array alongside (or instead of) data. Always check for errors before trusting data.

200 error envelope
{
  "errors": [
    { "message": "Not Authorized to access getProducts" }
  ]
}
errorType / messageCauseFix
Not AuthorizedMissing, expired, or wrong-tier token.Re-run login or refresh; confirm the JWT is in the Authorization header (raw, no Bearer).
401 UnauthorizedExceptionOn a protected call: the JWT is in the wrong header — a header literally named authorizationToken is not read. On a public call: bad or absent x-api-key.Put the JWT in Authorization; for public calls check the API key header and value.
MissingFieldArgumentA required argument was omitted (e.g. pricelistId on getProducts).Check the signature in the schema section or the explorer.
UnknownArgumentAn argument that doesn't exist in the schema.Introspect the field; argument names are exact.
FieldUndefinedSelecting a field the type doesn't have.Check the type in the explorer's schema tab.
Could not reach the authentication serverlogin couldn't authenticate against the serverUrl/database pair — wrong values or wrong credentials for that database.Confirm you're using the environment's exact serverUrl and database (see Connect) and that your user was provisioned in that database.
08

REST endpoints legacy

A small REST surface exists for backward compatibility. Prefer GraphQL for new work — these routes will not gain features. Base URL: https://<id>.execute-api.<region>.amazonaws.com/<env>.

MethodPathPurpose
POST/auth/loginAuthenticate (use GraphQL login instead).
GET/partnersSearch customers.
POST/partnersCreate a customer.
PUT/partners/{id}Update a customer.
GET/productsSearch products.
GET/pricelistsList pricelists.
09

Create an order

An order isn't posted as one payload. You build a cart, convert it to a draft order, attach payments, confirm it, and push it to the ERP — six steps, all on the same GraphQL endpoint with your JWT. The server computes pricing, tax, and promotions from the pricelist at each step, so you never send totals: you send intent and read back what the cart resolved to.

Why the sequence mattersEach step validates against the last, so they can't be reordered or skipped. In particular confirmOrder refuses an order whose payments don't cover the total, and syncOrderToOdoo refuses an order with no cashier stamped on it. Both fail loudly — check errors after every call rather than only at the end.
flow cart → order → ERP
  YOUR SYSTEM              SBCoreX                        ODOO ERP

  createCart          ──▶  cart (prices resolved here)
  addItemToCart   ×N  ──▶  cart recalculated  ◀── read `total` back
  setCartCustomer     ──▶  cart

  createOrderFromCart ──▶  ORDER · DRAFT
  addPaymentToOrder ×N ─▶  payments attached
                             │
  confirmOrder        ──▶  ─┤ gate 1: amountPaid must cover amountTotal
                             ▼
                           ORDER · PAID
  setOrderEmployee    ──▶  cashier stamped
                             │
  syncOrderToOdoo     ──▶  ─┤ gate 2: cashier must be stamped
                             ▼
                                                        pos.order created
                                                        stock + accounting move
                           ◀── odooOrderId, barcode

What you need first

ValueWhere it comes from
pointOfSaleIdThe point of sale you're selling through — provided at onboarding.
pricelistIdgetPricelists. The same pricelist you priced the catalog against.
posSessionIdThe open session for that point of sale. An order can't be booked into a closed session.
variantIdProductVariant.id from getProducts — the sellable unit, not the product.
paymentMethodIdgetPaymentMethods(posId) for that point of sale.
employeeIdThe hr.employee id of the cashier responsible for the sale. Required before sync.
customerIdsearchPartners or createPartner. Optional — omit for an anonymous sale.

The sequence

1 · Mutation
createCart(pointOfSaleId: ID!, pricelistId: ID!): Cart!
Opens an empty cart. Keep the returned Cart.id — every following cart call needs it.
2 · Mutation
addItemToCart(cartId: ID!, variantId: ID!, quantity: Int!): Cart!
Once per line. Returns the whole recalculated cart — subtotal, taxAmount, discountAmount, total, and any promotions that fired. Use updateItemQuantity / removeItemFromCart to correct a line.
3 · Mutation
setCartCustomer(cartId: ID!, customerId: ID): Cart!
Optional, but the cart is the single source of truth for the order's customer — set it here, not on the order. Pass null to clear.
4 · Mutation
createOrderFromCart(cartId: ID!, tabNumber: Int, posSessionId: ID, loginNumber: Int): Order!
Freezes the cart into an Order in state DRAFT, copying lines, customer, and cashier. Returns the Order.id (order-<uuid>) you use from here on.
5 · Mutation
addPaymentToOrder(orderId: ID!, paymentMethodId: ID!, amount: Float!, transactionId: String, installments: Int): Order!
Once per tender — call it repeatedly to split across methods. Card payments carry the acquirer's transactionId. removePaymentFromOrder undoes one.
6 · Mutation
confirmOrder(orderId: ID!): Order!
Validates the order is fully paid and moves DRAFTPAID. Rejected if amountPaid < amountTotal or if the order isn't DRAFT.
7 · Mutation
setOrderEmployee(orderId: ID!, employeeId: ID!): Order!
Stamps the responsible cashier. Not optional — the next step refuses to run without it.
8 · Mutation
syncOrderToOdoo(orderId: ID!, posSessionId: Int): SyncOrderResult!
Pushes the paid order into the ERP as a real POS order. Returns odooOrderId and the receipt barcode. This is the step that creates the accounting and stock movements.

Quickstart — the whole chain in one script

Fill in the seven ids at the top and run it. It authenticates, builds a one-line cart, pays it, confirms it, and returns the ERP order id — aborting with the server's message the moment any step reports an error. Requires curl and jq.

bash create-order.sh
#!/usr/bin/env bash
# Create one POS order end to end. Requires: curl, jq.
set -euo pipefail

ENDPOINT="https://<your-id>.appsync-api.us-east-2.amazonaws.com/graphql"
API_KEY="da2-xxxxxxxxxxxxxxxxxxxxxxxxxx"
ODOO_URL="https://dev.odoo-smartbamboo.mx"
ODOO_DB="smart-bamboo-dev"
USERNAME="partner@example.com"
PASSWORD="********"

POS_ID="366"            # pointOfSaleId
PRICELIST_ID="12"       # getPricelists
SESSION_ID="1187"       # open pos.session
VARIANT_ID="7210"       # ProductVariant.id from getProducts
QTY=1
PAYMENT_METHOD_ID="7"   # getPaymentMethods(posId)
EMPLOYEE_ID="3885"      # hr.employee id of the cashier
CUSTOMER_ID="88213"     # searchPartners; use null for an anonymous sale

# post <auth-header> <query> <variables-json>  → aborts on any GraphQL error
post() {
  curl -sS "$ENDPOINT" -H "$1" -H 'Content-Type: application/json' \
    --data "$(jq -nc --arg q "$2" --argjson v "$3" '{query:$q,variables:$v}')" \
  | jq -e 'if .errors then (.errors[0].message | halt_error(1)) else . end'
}

# 0 · authenticate ───────────────────────────────────────────────
JWT=$(post "x-api-key: $API_KEY" \
  'mutation($s:String!,$d:String!,$u:String!,$p:String!){
     login(serverUrl:$s,database:$d,username:$u,password:$p){ token } }' \
  "$(jq -nc --arg s "$ODOO_URL" --arg d "$ODOO_DB" \
            --arg u "$USERNAME" --arg p "$PASSWORD" '$ARGS.named')" \
  | jq -r '.data.login.token')
AUTH="Authorization: $JWT"
echo "authenticated"

# 1 · open a cart ────────────────────────────────────────────────
CART=$(post "$AUTH" \
  'mutation($pos:ID!,$pl:ID!){ createCart(pointOfSaleId:$pos,pricelistId:$pl){ id } }' \
  "$(jq -nc --arg pos "$POS_ID" --arg pl "$PRICELIST_ID" '$ARGS.named')" \
  | jq -r '.data.createCart.id')
echo "cart      $CART"

# 2 · add a line — the response is the authoritative price ───────
TOTAL=$(post "$AUTH" \
  'mutation($c:ID!,$v:ID!,$q:Int!){
     addItemToCart(cartId:$c,variantId:$v,quantity:$q){ total taxAmount } }' \
  "$(jq -nc --arg c "$CART" --arg v "$VARIANT_ID" --argjson q "$QTY" '$ARGS.named')" \
  | jq -r '.data.addItemToCart.total')
echo "total     $TOTAL"

# 3 · attach the customer (skip for an anonymous sale) ───────────
post "$AUTH" \
  'mutation($c:ID!,$p:ID){ setCartCustomer(cartId:$c,customerId:$p){ id customerName } }' \
  "$(jq -nc --arg c "$CART" --arg p "$CUSTOMER_ID" '$ARGS.named')" > /dev/null

# 4 · freeze the cart into a DRAFT order ─────────────────────────
ORDER=$(post "$AUTH" \
  'mutation($c:ID!,$s:ID){
     createOrderFromCart(cartId:$c,posSessionId:$s){ id state amountTotal } }' \
  "$(jq -nc --arg c "$CART" --arg s "$SESSION_ID" '$ARGS.named')" \
  | jq -r '.data.createOrderFromCart.id')
echo "order     $ORDER"

# 5 · pay it in full (repeat for a split tender) ─────────────────
post "$AUTH" \
  'mutation($o:ID!,$m:ID!,$a:Float!){
     addPaymentToOrder(orderId:$o,paymentMethodId:$m,amount:$a){ amountPaid amountTotal } }' \
  "$(jq -nc --arg o "$ORDER" --arg m "$PAYMENT_METHOD_ID" --argjson a "$TOTAL" '$ARGS.named')" \
  | jq -r '"paid      \(.data.addPaymentToOrder.amountPaid) of \(.data.addPaymentToOrder.amountTotal)"'

# 6 · confirm — DRAFT → PAID ─────────────────────────────────────
post "$AUTH" 'mutation($o:ID!){ confirmOrder(orderId:$o){ state } }' \
  "$(jq -nc --arg o "$ORDER" '$ARGS.named')" \
  | jq -r '"state     \(.data.confirmOrder.state)"'

# 7 · stamp the cashier — required before sync ───────────────────
post "$AUTH" \
  'mutation($o:ID!,$e:ID!){ setOrderEmployee(orderId:$o,employeeId:$e){ employeeId } }' \
  "$(jq -nc --arg o "$ORDER" --arg e "$EMPLOYEE_ID" '$ARGS.named')" > /dev/null

# 8 · push to the ERP ────────────────────────────────────────────
post "$AUTH" \
  'mutation($o:ID!,$s:Int){
     syncOrderToOdoo(orderId:$o,posSessionId:$s){ odooOrderId barcode } }' \
  "$(jq -nc --arg o "$ORDER" --argjson s "$SESSION_ID" '$ARGS.named')" \
  | jq -r '"odooOrder \(.data.syncOrderToOdoo.odooOrderId)  barcode \(.data.syncOrderToOdoo.barcode)"'
Prefer to hand us the order instead?If driving eight calls doesn't fit your system — you're exporting from another POS, a marketplace, or a nightly batch — we can take the order from you in one handoff and run this sequence on our side. Tell us the shape of the data you already have and we'll agree on the intake; don't build a partial chain and leave orders stranded mid-flow.

Walkthrough

One line, one cash payment, from empty cart to synced order. Every call is the same POST with your JWT in Authorization; only the body changes.

POST 1 · createCart
curl -X POST "$ENDPOINT" -H "Authorization: $JWT" \
  -H "Content-Type: application/json" -d '{
  "query": "mutation($pos: ID!, $pl: ID!) { createCart(pointOfSaleId: $pos, pricelistId: $pl) { id total } }",
  "variables": { "pos": "366", "pl": "12" }
}'

# → { "data": { "createCart": { "id": "cart-6f2a…", "total": 0 } } }
POST 2 · addItemToCart
-d '{
  "query": "mutation($c: ID!, $v: ID!, $q: Int!) { addItemToCart(cartId: $c, variantId: $v, quantity: $q) { id subtotal taxAmount discountAmount total items { id productName quantity unitPrice } } }",
  "variables": { "c": "cart-6f2a…", "v": "7210", "q": 1 }
}'

# The response is the authoritative price. Read `total` — do not compute it.
# → "total": 449.00, "taxAmount": 61.93
POST 3 · setCartCustomer (optional)
-d '{
  "query": "mutation($c: ID!, $p: ID) { setCartCustomer(cartId: $c, customerId: $p) { id customerId customerName total } }",
  "variables": { "c": "cart-6f2a…", "p": "88213" }
}'
POST 4 · createOrderFromCart
-d '{
  "query": "mutation($c: ID!, $s: ID) { createOrderFromCart(cartId: $c, posSessionId: $s) { id name state amountTotal amountTax amountPaid } }",
  "variables": { "c": "cart-6f2a…", "s": "1187" }
}'

# → "id": "order-9b41…", "state": "DRAFT", "amountTotal": 449.00, "amountPaid": 0
POST 5 · addPaymentToOrder
-d '{
  "query": "mutation($o: ID!, $m: ID!, $a: Float!) { addPaymentToOrder(orderId: $o, paymentMethodId: $m, amount: $a) { id state amountPaid amountTotal payments { id paymentMethodName amount state } } }",
  "variables": { "o": "order-9b41…", "m": "7", "a": 449.00 }
}'

# Repeat with a different paymentMethodId to split the tender.
# → "amountPaid": 449.00 — must reach amountTotal before step 6.
POST 6 · confirmOrder
-d '{
  "query": "mutation($o: ID!) { confirmOrder(orderId: $o) { id state datePaid amountPaid } }",
  "variables": { "o": "order-9b41…" }
}'

# → "state": "PAID"
POST 7 · setOrderEmployee
-d '{
  "query": "mutation($o: ID!, $e: ID!) { setOrderEmployee(orderId: $o, employeeId: $e) { id employeeId } }",
  "variables": { "o": "order-9b41…", "e": "3885" }
}'
POST 8 · syncOrderToOdoo
-d '{
  "query": "mutation($o: ID!, $s: Int) { syncOrderToOdoo(orderId: $o, posSessionId: $s) { orderId odooOrderId barcode } }",
  "variables": { "o": "order-9b41…", "s": 1187 }
}'

# → { "orderId": "order-9b41…", "odooOrderId": 275190, "barcode": "…" }
#   odooOrderId is what getOrderById expects. Persist it.

Verify it landed

The order exists in the ERP only after step 8 returns. Read it back with the odooOrderId that step gave you — this is the same id searchOrders later reports as Order.odooOrderId.

POST verify · getOrderById
-d '{
  "query": "query($id: ID!) { getOrderById(orderId: $id) { name posReference state amountTotal amountPaid datePaid lines { productName quantity priceTotal } } }",
  "variables": { "id": "275190" }
}'

# Pass the ERP id (275190), not order-<uuid>. Before step 8 this returns null.

When a step fails

Every failure comes back as HTTP 200 with an errors array — check it after each call, not just at the end. These are the messages this flow actually produces:

MessageWhat happenedFix
Order <id> not foundAlmost always the wrong id shape — the ERP odooOrderId passed where order-<uuid> belongs.Steps 4–8 all take Order.id (order-<uuid>). Only getOrderById takes the ERP id.
Cannot confirm order <id> in state PAID. Order must be in DRAFT state.confirmOrder ran twice — it is not idempotent.Treat it as already done. Read state before retrying rather than calling blind.
Order <id> is not fully paid. Amount paid: X, amount total: YPayments don't cover the total at cent precision.Add the shortfall with another addPaymentToOrder. Use the order's own amountTotal as the target, never a locally-computed figure.
… total is 0 with no discount or coupon recordedA zero/negative-total order with nothing explaining the reduction — guards against shipping free goods untraceably.Only confirm a zero total when a real discount or coupon produced it.
Order <id> has no confirmed cashier; refusing to syncStep 7 was skipped. The sale is PAID on our side and will never reach the ERP.Call setOrderEmployee, then retry step 8. This is the most common way an integration silently loses orders.
Not AuthorizedThe JWT expired mid-flow (they last roughly 24h).refreshToken, or re-run login, then continue from the step that failed — completed steps stay done.

Retries and idempotency

None of these mutations are idempotent, so a blind retry after a timeout can double-charge or duplicate a line. The order is the durable record — read its state and retry from there rather than replaying the whole chain.

If this timed outDo this
addItemToCartRe-read the cart and compare items before adding again — a retry adds a second line.
addPaymentToOrderRe-read payments and amountPaid first. A blind retry books the tender twice and then confirmOrder succeeds on an over-paid order.
confirmOrderRead state. PAID means it worked; the second call errors rather than confirming twice.
syncOrderToOdooSafe to retry — the ERP de-duplicates on the order reference, so a repeat returns the existing order instead of creating a second one.

Order states

Order.state is the enum DRAFT · PAID · DONE · INVOICED · CANCELLED. A new order is DRAFT; confirmOrder makes it PAID; cancelOrder makes it CANCELLED. Payments carry their own state — OrderPayment.state is DRAFT · DONE · CANCELLED.

Gotchas

Read this before your first run
  • Never compute money client-side. The cart response is authoritative — pricelist rules, promotions, and tax all resolve server-side. Read total back; a locally-derived total will eventually disagree.
  • Carts are short-lived. A cart is a working document, not storage: don't build one and come back tomorrow. Convert it in the same session, or persist a quotation with saveWishlist instead.
  • One writer per cart. Cart mutations don't take a version, so two clients writing the same cart concurrently can lose an update. Drive a given cart from a single process.

Coupons, promotions, home delivery, quotations, installation services, and terminal-driven card payments all hook into this same chain with extra mutations. Ask us to scope the flow you need before you build against it — several of those steps have ordering requirements of their own.

10

Changelog

Notable corrections and additions to this guide. Newest first.

DateChange
2026-08-05Added "Create an order". Documents order creation end to end: a flow diagram, a runnable one-file bash quickstart, the eight mutations with prerequisite ids, a curl per step, how to verify the order landed, the exact error messages each step produces, retry/idempotency rules per mutation, and the two gates that reject an order — unpaid at confirmOrder, no stamped cashier at syncOrderToOdoo.
2026-07-16Auth header corrected. Protected calls use the Authorization header (raw JWT, no Bearer), not a header named authorizationToken — the latter returns 401 UnauthorizedException. Updated the Authenticate section, curl example, explorer instructions, and Errors table.
2026-07-16getOrderById id clarified. Documented that orderId is the Odoo id (Order.odooOrderId), not the order-<uuid> from searchOrders (Order.id). Added odooOrderId to the Order type and an "Order ids" note, including that it is null until the order syncs to Odoo.