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.
| Value | Shape | What it's for |
|---|---|---|
| Endpoint URL | https://<id>.appsync-api.<region>.amazonaws.com/graphql | Where every request is POSTed. |
| API key | da2-… (AWS AppSync format) | Opens the public endpoints (login, ping). Sent as x-api-key. |
| Region | us-east-2 | The AWS region the endpoint runs in. |
| serverUrl | https://dev.odoo-smartbamboo.mx (test) | Passed to login — identifies the backing ERP for your environment. |
| database | smart-bamboo-dev (test) | Passed to login alongside serverUrl. |
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.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.
JWT token
Sent on every protected query and mutation. Obtained from login. Send the raw JWT — no Bearer prefix.
API key
For the open endpoints — login, ping, isTokenValid.
AWS IAM
SigV4-signed, reserved for our own service-to-service webhooks.
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.
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.
mutation Refresh { refreshToken { token } }
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.
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:
{
"data": {
"getProducts": [
{
"product": { "id": "5659", "name": "Almohada Memory", "basePrice": 499.00 },
"pricedVariants": [ { "variantId": "7210", "price": 449.00 } ]
}
]
}
}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.
curl example above instead.Powered by Apollo's embedded Explorer. Nothing you type here is stored on this page.
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
product + pricedVariants.lastModified per pricelist — poll it to detect price changes.User shape (no phone/vat).orderId is the Odoo id — pass Order.odooOrderId, not Order.id (the order-<uuid> from searchOrders is rejected). The receipt reference is posReference.Mutations — write data
token + user).Partner (phone, vat, address).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.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 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 }
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 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 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.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.
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.
{
"errors": [
{ "message": "Not Authorized to access getProducts" }
]
}| errorType / message | Cause | Fix |
|---|---|---|
Not Authorized | Missing, expired, or wrong-tier token. | Re-run login or refresh; confirm the JWT is in the Authorization header (raw, no Bearer). |
401 UnauthorizedException | On 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. |
MissingFieldArgument | A required argument was omitted (e.g. pricelistId on getProducts). | Check the signature in the schema section or the explorer. |
UnknownArgument | An argument that doesn't exist in the schema. | Introspect the field; argument names are exact. |
FieldUndefined | Selecting a field the type doesn't have. | Check the type in the explorer's schema tab. |
Could not reach the authentication server | login 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. |
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>.
| Method | Path | Purpose |
|---|---|---|
POST | /auth/login | Authenticate (use GraphQL login instead). |
GET | /partners | Search customers. |
POST | /partners | Create a customer. |
PUT | /partners/{id} | Update a customer. |
GET | /products | Search products. |
GET | /pricelists | List pricelists. |
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.
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.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
| Value | Where it comes from |
|---|---|
pointOfSaleId | The point of sale you're selling through — provided at onboarding. |
pricelistId | getPricelists. The same pricelist you priced the catalog against. |
posSessionId | The open session for that point of sale. An order can't be booked into a closed session. |
variantId | ProductVariant.id from getProducts — the sellable unit, not the product. |
paymentMethodId | getPaymentMethods(posId) for that point of sale. |
employeeId | The hr.employee id of the cashier responsible for the sale. Required before sync. |
customerId | searchPartners or createPartner. Optional — omit for an anonymous sale. |
The sequence
Cart.id — every following cart call needs it.subtotal, taxAmount, discountAmount, total, and any promotions that fired. Use updateItemQuantity / removeItemFromCart to correct a line.null to clear.Order in state DRAFT, copying lines, customer, and cashier. Returns the Order.id (order-<uuid>) you use from here on.transactionId. removePaymentFromOrder undoes one.DRAFT → PAID. Rejected if amountPaid < amountTotal or if the order isn't DRAFT.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.
#!/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)"'
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.
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 } } }
-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
-d '{
"query": "mutation($c: ID!, $p: ID) { setCartCustomer(cartId: $c, customerId: $p) { id customerId customerName total } }",
"variables": { "c": "cart-6f2a…", "p": "88213" }
}'-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
-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.
-d '{ "query": "mutation($o: ID!) { confirmOrder(orderId: $o) { id state datePaid amountPaid } }", "variables": { "o": "order-9b41…" } }' # → "state": "PAID"
-d '{
"query": "mutation($o: ID!, $e: ID!) { setOrderEmployee(orderId: $o, employeeId: $e) { id employeeId } }",
"variables": { "o": "order-9b41…", "e": "3885" }
}'-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.
-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:
| Message | What happened | Fix |
|---|---|---|
Order <id> not found | Almost 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: Y | Payments 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 recorded | A 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 sync | Step 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 Authorized | The 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 out | Do this |
|---|---|
addItemToCart | Re-read the cart and compare items before adding again — a retry adds a second line. |
addPaymentToOrder | Re-read payments and amountPaid first. A blind retry books the tender twice and then confirmOrder succeeds on an over-paid order. |
confirmOrder | Read state. PAID means it worked; the second call errors rather than confirming twice. |
syncOrderToOdoo | Safe 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
- Never compute money client-side. The cart response is authoritative — pricelist rules, promotions, and tax all resolve server-side. Read
totalback; 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
saveWishlistinstead. - 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.
Changelog
Notable corrections and additions to this guide. Newest first.
| Date | Change |
|---|---|
2026-08-05 | Added "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-16 | Auth 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-16 | getOrderById 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. |