{"openapi":"3.1.0","info":{"title":"Najwa's Wardrobe — POS Integration API","version":"1.0.0-draft.4","summary":"Contract between the online shop and the in-store POS system.","description":"This API lets the online shop read the catalogue and the stock of the shop,\nhold a piece while a customer pays, and record the order.\n\n## Who builds what\n\nThe POS supplier builds this API. The API reads and writes the POS database.\nThe online shop calls this API. **The online shop never connects to the POS\ndatabase directly.** How the supplier stores the data is his decision.\n\n## The main rules\n\n1. **A variant is three fields together:** `pos_code`, `colour_code` and\n   `size_code`. Do not use the barcode. Six barcodes in the POS database\n   belong to more than one product, so a barcode cannot identify a product.\n2. **`POST /orders` and `POST /reservations` must be safe to repeat.** If\n   the network fails, the online shop sends the same request again. The API\n   must not create a second order or a second hold.\n3. **An order is all or nothing.** If one line fails, write no line at all.\n4. **Money is a text value, not a decimal number.** For example `\"28.00\"`.\n   This prevents rounding errors.\n5. **All times are ISO 8601 with a time zone.** For example\n   `2026-09-23T14:05:00+03:00`.\n6. **Send the real stock number**, even a negative one. The online shop\n   corrects it before it shows the product.\n\n## How the online shop reads data\n\nThe online shop calls this API at regular times. **The API never calls the\nonline shop. There are no webhooks.**\n\n| What | How often |\n|---|---|\n| `GET /stock?since=` | every 2 to 5 minutes |\n| `GET /stock` (everything) | one time each night |\n| `GET /products?since=` | every hour |\n| `GET /products` (everything) | one time each night |\n\nThere is no limit on the number of calls.\n\n## Product images\n\nThe POS supplier exposes image metadata on products and the original image\nbytes through authenticated `GET /media/{image_id}` downloads. Local file\npaths are not usable by the online shop. The supplier is responsible for\nmaking locally stored files accessible to his API.\n\nThe online shop downloads new or changed images, verifies their SHA-256\nchecksum, and uploads copies to its own Medusa file storage. The website\nserves those copies, never the POS download endpoint. The POS token stays\non the backend. There are no image uploads from Medusa to the POS.\n\nMedusa remains the editorial authority. Synchronisation may update or remove\nonly images still managed by the POS integration; manually uploaded images,\nmanual replacements, ordering and thumbnail choices must be preserved.\nMissing metadata, an empty list, or a failed download never clears images.\nOnly an explicit image tombstone can request removal of a POS-managed copy.\n\n## Not in this API\n\n- **Returns with money back.** The shop gives no money back, ever. A\n  customer can exchange a piece for another piece within two days, and\n  `POST /orders/{external_ref}/exchange` records that.\n- **Delivery.** The online shop holds the address and the delivery cost.\n- **Tax.** The online shop calculates tax.\n- **Which products the website shows.** Najwa decides this in the admin page\n  of the online shop.\n\n## The customer and her points\n\nThe endpoints under `/customers` let the website show a customer her loyalty\npoints and what she bought in the shop. They are **read only**, and the\nonline shop calls them for one customer at a time, when that customer is\nsigned in. They are not a synchronisation.\n\n**The online shop identifies the customer. This API never does.** The\nwebsite asks the customer for her password, and the website keeps the link\nbetween her website account and her POS customer number. A telephone number\nthat matches is a candidate, never a proof, and a token on this API is never\na permission to read the data of a person.\n\n## Prototype status\n\nThis is a proposed vendor contract, not an implemented vendor service.\n`mock-pos-api` exercises it with fictional data. Draft 3 adds explicit stock\ntrust and verified customer IDs. Draft 4 adds product image metadata and\nauthenticated image downloads; these are not yet implemented by the mock\nor the Medusa importer. Points redemption is part of `POST /orders`.\nSee `REVIEW.md` for unresolved business rules and mock-only assumptions.\n","contact":{"name":"Najwa's Wardrobe e-commerce team"},"license":{"name":"Private contract"}},"servers":[{"url":"https://{host}/v1","description":"Test server. We need this before the live server.","variables":{"host":{"default":"pos-api-test.example.com"}}},{"url":"https://{host}/v1","description":"Live server","variables":{"host":{"default":"pos-api.example.com"}}}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Operations"},{"name":"Catalogue"},{"name":"Stock"},{"name":"Reservations"},{"name":"Orders"},{"name":"Customers"},{"name":"Loyalty"},{"name":"History"}],"paths":{"/health":{"get":{"tags":["Operations"],"summary":"Is the service available","description":"The sync job calls this before it starts. It needs no token.","security":[],"operationId":"getHealth","responses":{"200":{"description":"The service is available.","content":{"application/json":{"schema":{"type":"object","required":["status","time"],"properties":{"status":{"type":"string","enum":["ok"]},"time":{"type":"string","format":"date-time"},"version":{"type":"string"}}},"example":{"status":"ok","time":"2026-09-23T14:05:00+03:00","version":"1.0.3"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/products":{"get":{"tags":["Catalogue"],"summary":"Get the products","description":"Returns the products, their variants, and image metadata when supported.\nImage bytes are downloaded separately through `GET /media/{image_id}`.\nAdding, changing, reordering or deleting an image must update the parent\nproduct's `updated_at`, so `since` includes image-only changes.\n\n**Everything:** call without `since`. Follow `next_cursor` until null.\n\n**Only the changes:** call with `since`.\n\n**A product with no variants is correct.** Send an empty list. In the\nPOS database 15 products have no stock movement, so they have no colour\nand no size.\n\n**A deleted product must stay in this list**, with `deleted` set to\n`true`. If the row only disappears, the online shop cannot know that it\nmust remove the product from the website.\n\n## The order of the rows\n\nSort by `pos_code`, from the smallest to the largest. Always use this\norder. A cursor needs a fixed order: without one, the same row can come\ntwo times, or it can be lost between two pages.\n","operationId":"listProducts","parameters":[{"$ref":"#/components/parameters/Since"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of products.","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor","server_time"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Product"}},"next_cursor":{"$ref":"#/components/schemas/NextCursor"},"server_time":{"$ref":"#/components/schemas/ServerTime"}}},"examples":{"onePage":{"summary":"A product with a discount, and two variants","value":{"data":[{"pos_code":"1292","title":"q tishirt bag flower- croshet","description":null,"category":"tshirt","brand":null,"season":"summer","price":{"amount":"20.00","currency":"USD"},"sale_price":{"amount":"15.00","currency":"USD"},"deleted":false,"updated_at":"2026-09-16T11:00:00+03:00","images":[{"id":"img_1292_white_front","deleted":false,"download_path":"/v1/media/img_1292_white_front","content_type":"image/jpeg","byte_size":245760,"checksum_sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","updated_at":"2026-09-16T11:00:00+03:00","position":0,"colour_code":"32"},{"id":"img_1292_old_back","deleted":true,"updated_at":"2026-09-16T11:00:00+03:00"}],"variants":[{"colour_code":"32","colour":"white","size_code":"24","size":"m","barcode":"11292"},{"colour_code":"02","colour":"black","size_code":"28","size":"s","barcode":"11292"}]}],"next_cursor":null,"server_time":"2026-09-23T14:05:00+03:00"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/products/{pos_code}":{"get":{"tags":["Catalogue"],"summary":"Get one product","description":"Used to repair one row when the online shop finds a problem. It is not\nused for the normal synchronisation.\n","operationId":"getProduct","parameters":[{"name":"pos_code","in":"path","required":true,"description":"The product code. In the POS this is `tblItemsinit.Code`.","schema":{"type":"string","examples":["1292"]}}],"responses":{"200":{"description":"The product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Product"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/media/{image_id}":{"get":{"tags":["Catalogue"],"summary":"Download a product image","description":"Returns the original file bytes for an active product image, using the\nsame bearer token as the catalogue endpoints. The supplier reads the\nfile from his storage; never return a local path, JSON with base64 data,\nan HTML page, or a redirect to another host.\n\nThe Content-Type and byte length must match the image metadata. The\nSHA-256 checksum is calculated over the returned file bytes, without\nresizing or recompression. The endpoint returns the current version.\nIf the image changes between reading metadata and downloading, the\nimporter must reject a checksum mismatch, fetch fresh product metadata\nand retry. Keep the existing Medusa image until replacement succeeds.\n\nReturn `404 not_found` for an unknown or explicitly deleted image.\nA temporary storage/read failure is `500 internal_error`, not deletion.\nThis endpoint is for backend imports, not browser image rendering.\n","operationId":"downloadProductImage","parameters":[{"name":"image_id","in":"path","required":true,"description":"The stable image ID from the product's images list.","schema":{"$ref":"#/components/schemas/ProductImageId"}}],"responses":{"200":{"description":"The original image bytes, not a JSON response.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/stock":{"get":{"tags":["Stock"],"summary":"Get the stock quantities","description":"Returns the quantity of each variant.\n\nThe quantity must include the sales that the staff made at the counter.\n\n**Two numbers come back:**\n\n- `on_hand` — the pieces in the shop\n- `available` — `on_hand` minus the pieces that are on hold\n\n**The online shop sells against `available`.**\n\nIf the POS calculation gives a negative `on_hand`, send that number. Do\nnot change it to `0`. The online shop corrects it before it shows the\nproduct. A negative number tells us that something is wrong in the POS,\nand we want to see it.\n\n## The order of the rows\n\nSort by `pos_code`, then by `colour_code`, then by `size_code`, from the\nsmallest to the largest. Always use this order. A cursor needs a fixed\norder: without one, the same row can come two times, or it can be lost\nbetween two pages.\n","operationId":"listStock","parameters":[{"$ref":"#/components/parameters/Since"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of stock quantities.","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor","server_time"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/StockLevel"}},"next_cursor":{"$ref":"#/components/schemas/NextCursor"},"server_time":{"$ref":"#/components/schemas/ServerTime"}}},"example":{"data":[{"pos_code":"1292","colour_code":"32","size_code":"24","on_hand":2,"available":1,"calculation_status":"verified","issues":[],"updated_at":"2026-09-16T11:00:00+03:00"}],"next_cursor":null,"server_time":"2026-09-23T14:05:00+03:00"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/reservations":{"post":{"tags":["Reservations"],"summary":"Hold the pieces while the customer pays","description":"## Why this is necessary\n\nMost variants in the shop have **one piece only**. If a customer buys on\nthe website at the same time as a customer buys at the counter, one of\nthem cannot get the product.\n\nThe online shop calls this when the customer starts to pay. The API\nholds the pieces. The held pieces are removed from `available` in\n`GET /stock`, so the website stops offering them.\n\n## The hold ends in one of three ways\n\n1. The online shop sends `POST /orders` with the `reservation_id`. The\n   hold becomes a sale.\n2. The online shop sends `DELETE /reservations/{reservation_id}`.\n3. The time in `expires_at` passes. **The API releases the hold by\n   itself.** The online shop does not have to call anything.\n\nThe third way is important. If the online shop stops working in the\nmiddle of a payment, the pieces must go back to the shop.\n\n## A hold does not change `on_hand`\n\nA hold is not a sale. The pieces are still in the shop. Only `available`\nbecomes smaller.\n\n## Repeated calls\n\nThe hold is identified by `external_ref`, which is the basket number of\nthe online shop.\n\n- New `external_ref`: make the hold. Return `201`.\n- Same `external_ref` again: return `200` with the hold that exists.\n  Do not hold the pieces a second time.\n- Same reference with different lines or TTL: `409 idempotency_key_reuse`.\n- Repeating an expired or released hold returns that terminal hold.\n  A new checkout attempt needs a new `external_ref`.\n- Duplicate variant lines count together when checking availability.\n","operationId":"createReservation","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReservationRequest"},"example":{"external_ref":"najwa-cart-5521","ttl_seconds":900,"lines":[{"pos_code":"1292","colour_code":"32","size_code":"24","quantity":1}]}}}},"responses":{"200":{"description":"This hold exists already. The API did not make a second one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reservation"}}}},"201":{"description":"The API made the hold.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reservation"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"The API cannot hold the pieces. Read `error.code`:\n\n- `insufficient_stock` — not enough pieces are available. Put the\n  quantity that is available in `error.details`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"$ref":"#/components/responses/Unprocessable"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/reservations/{reservation_id}":{"get":{"tags":["Reservations"],"summary":"Read a hold","description":"The online shop calls this when it does not know the result of\n`POST /reservations`.\n","operationId":"getReservation","parameters":[{"$ref":"#/components/parameters/ReservationId"}],"responses":{"200":{"description":"The hold.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reservation"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"tags":["Reservations"],"summary":"Release the hold","description":"The online shop calls this when the customer leaves, or when the payment\nfails.\n\n**This call is safe to repeat.** If the hold is released already, or if\nthe time passed, return `204`. Do not return an error.\n","operationId":"deleteReservation","parameters":[{"$ref":"#/components/parameters/ReservationId"}],"responses":{"204":{"description":"The hold is released, or it was released before."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"The API cannot release this hold, because it is a sale now. Read\n`error.code` — `reservation_already_used`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/ServerError"}}}},"/orders":{"post":{"tags":["Orders"],"summary":"Record an order from the website","description":"The online shop calls this after the customer pays.\n\nThe API writes the order into the POS. **The API chooses the POS sale\nnumber.** The online shop never chooses it.\n\n## The order is all or nothing\n\nIf the API cannot write one line, it must write no line. A part of an\norder must never stay in the POS.\n\n## Use the hold\n\nIf the online shop sends `reservation_id`, the API turns that hold into\nthe sale. The API must not remove the stock two times.\n\nThe order's aggregate variant quantities must exactly match the hold.\nOtherwise return `400 bad_request`, leaving the hold unchanged.\n\nIf `reservation_id` is missing, the API takes the stock directly. If the\npieces are not there, return `409 insufficient_stock`.\n\n## Repeated calls\n\nThe online shop sends an `Idempotency-Key` header. The API stores it for\n**at least 30 days**.\n\n- New key: create the order. Return `201`.\n- Same key, same body: **do not create a second order.** Return `200`\n  with the first result.\n- Same key, different body: return `409 idempotency_key_reuse`.\n\nThis is necessary. If the network fails after the API writes the order,\nthe online shop does not know the result. It sends the request again.\n","operationId":"createOrder","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderRequest"},"example":{"external_ref":"najwa-web-1042","reservation_id":"rsv_01J9F2K7QX","channel":"WEB","occurred_at":"2026-09-23T14:05:00+03:00","customer":{"phone":"+9613111222","loyalty_card":"233000160","name":"Maya Issa"},"lines":[{"pos_code":"1292","colour_code":"32","size_code":"24","quantity":1,"unit_price":{"amount":"20.00","currency":"USD"},"discount":{"type":"PCT","value":"25"},"net_price":{"amount":"15.00","currency":"USD"}}],"payment":{"method":"card","amount":{"amount":"15.00","currency":"USD"}}}}}},"responses":{"200":{"description":"This key was used before. This is the order from the first call. The\nAPI did not create a second order.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"201":{"description":"The API created the order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"A conflict. Read `error.code`:\n\n- `idempotency_key_reuse` — the same key with a different body\n- `external_ref_exists` — this order number exists already\n- `insufficient_stock` — the pieces are not in the shop\n- `reservation_expired` — the hold ended before the order arrived\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"$ref":"#/components/responses/Unprocessable"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/orders/{external_ref}":{"get":{"tags":["Orders"],"summary":"Read an order","description":"The online shop calls this when it does not know the result of\n`POST /orders`. For example after a timeout.\n\n**The meaning of the answer is important:**\n\n- `200` — the order exists. Do not send it again.\n- `404` — the order does not exist. It is safe to send it again.\n","operationId":"getOrder","parameters":[{"$ref":"#/components/parameters/ExternalRef"}],"responses":{"200":{"description":"The order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/orders/{external_ref}/cancel":{"post":{"tags":["Orders"],"summary":"Cancel an order","description":"The online shop calls this when the payment fails, or when the customer\ncancels before the shop sends the goods.\n\nThe API must put the pieces back into the stock.\n\n**This call is safe to repeat.** If the order is cancelled already,\nreturn `200` and do nothing.\n","operationId":"cancelOrder","parameters":[{"$ref":"#/components/parameters/ExternalRef"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"The order is cancelled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The API cannot cancel this order. Read `error.code` —\n`order_not_cancellable`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/ServerError"}}}},"/orders/{external_ref}/exchange":{"post":{"tags":["Orders"],"summary":"Exchange the pieces of an order","description":"The customer brings a piece back and takes a different piece. The shop\nallows this for **two days** after the sale. The shop gives **no money\nback**, ever.\n\nNajwa makes the exchange in the admin page of the online shop. The\nonline shop then calls this endpoint, so the POS learns about it.\n\n**This call is necessary.** Without it the two pieces stay wrong in the\nPOS for ever: the piece that came back is never added, and the piece\nthat went out is never removed.\n\n## What the API must do\n\n1. Add the pieces of `inbound_lines` back to the stock.\n2. Remove the pieces of `outbound_lines` from the stock.\n3. Write the exchange into the POS, so the staff can see it.\n\nThe API chooses the POS document number, the same as for an order.\n\n## Both sides are necessary\n\nAn exchange must hold **at least one inbound line and at least one\noutbound line**. A piece that comes back with nothing going out is a\nreturn, and this API does not do returns.\n\n## The difference is never below zero\n\n`difference` is the money the customer pays on top. It is `0` when the\ntwo sides have the same value.\n\n**It can never be below zero.** The shop gives no money back. If the\nnew pieces are worth less than the old pieces, the API returns\n`409 refund_not_supported`. The online shop must correct the lines.\n\n## The order must exist\n\nThe `external_ref` is the order of the first sale. If that order does\nnot exist, or the API cannot exchange it, read `error.code`.\n\n## The call is all or nothing\n\nIf the API cannot write one line, it must write no line.\n\n## Repeated calls\n\nThe online shop sends an `Idempotency-Key` header, the same as for\n`POST /orders`. The rules are the same.\n","operationId":"createExchange","parameters":[{"$ref":"#/components/parameters/ExternalRef"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExchangeRequest"},"example":{"exchange_ref":"najwa-web-1042-x1","occurred_at":"2026-09-25T11:20:00+03:00","reason":"wrong size","inbound_lines":[{"pos_code":"1292","colour_code":"32","size_code":"24","quantity":1,"net_price":{"amount":"15.00","currency":"USD"}}],"outbound_lines":[{"pos_code":"1292","colour_code":"32","size_code":"28","quantity":1,"net_price":{"amount":"18.00","currency":"USD"}}],"difference":{"amount":"3.00","currency":"USD"},"payment":{"method":"card","amount":{"amount":"3.00","currency":"USD"}}}}}},"responses":{"200":{"description":"This key was used before. This is the exchange from the first call.\nThe API did not write a second one.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Exchange"}}}},"201":{"description":"The API wrote the exchange.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Exchange"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"A conflict. Read `error.code`:\n\n- `refund_not_supported` — the new pieces are worth less than the\n  old pieces. The shop gives no money back.\n- `insufficient_stock` — the new pieces are not in the shop.\n- `order_not_exchangeable` — the order is cancelled, or the API\n  cannot change it.\n- `exchange_ref_exists` — this exchange number exists already.\n- `idempotency_key_reuse` — the same key with a different body.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"$ref":"#/components/responses/Unprocessable"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/customers":{"get":{"tags":["Customers"],"summary":"Find a customer","description":"The online shop calls this to find a customer who already exists in the\nPOS, so the website can show her loyalty points.\n\n**This is not a synchronisation.** The online shop does not copy the\ncustomer list every night. It calls this for one customer, when that\ncustomer is signed in on the website.\n\n## A telephone number is not a name tag\n\nThe POS customer table has no email column, so the telephone number is\nthe only value we can search. **More than one customer can have the\nsame telephone number**, and in the POS today some do.\n\nSo this endpoint returns a **list of candidates**, even when the list\nholds one row. The API must never join two customers into one, and must\nnever choose one of them for us.\n\n**The online shop decides which customer is the right one**, and the\nonline shop remembers that decision. A telephone number that matches is\nnot a proof of identity, and it is not a permission to read the data.\n\n## The telephone number in the POS\n\nThe online shop sends the number in one form only: a `+`, then the\ncountry, then the number, with no space and no other sign. For example\n`+9613111222`. In a web address the `+` is written `%2B`.\n\nThe POS stores telephone numbers as free text, so the same number can be\nwritten in several ways. **The API makes the comparison.** The supplier\nknows how the staff enter a number, so the supplier decides how to match\nit. Do not ask the online shop to guess the form.\n\n## Without a filter\n\nWith no `phone` and no `card_number`, the API returns every customer,\npage by page. The online shop uses this one time, to prepare the link\nbetween the two systems.\n\n## The order of the rows\n\nSort by `customer_id`, from the smallest to the largest. Always use this\norder, with a filter and without a filter.\n","operationId":"listCustomers","parameters":[{"$ref":"#/components/parameters/PhoneFilter"},{"$ref":"#/components/parameters/CardNumberFilter"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of customers.\n\n**An empty list is a normal answer**, not an error. It means that no\ncustomer has this telephone number.\n","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor","server_time"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Customer"}},"next_cursor":{"$ref":"#/components/schemas/NextCursor"},"server_time":{"$ref":"#/components/schemas/ServerTime"}}},"examples":{"twoCandidates":{"summary":"Two customers share one telephone number","value":{"data":[{"customer_id":"cus_00412","name":"Maya Issa","phone":"+9613111222","card_number":"233000160","address":"Kousba, Koura","updated_at":"2026-09-16T11:00:00+03:00"},{"customer_id":"cus_00913","name":null,"phone":"+9613111222","card_number":null,"address":null,"updated_at":"2025-04-02T09:20:00+03:00"}],"next_cursor":null,"server_time":"2026-09-23T14:05:00+03:00"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/customers/{customer_id}":{"get":{"tags":["Customers"],"summary":"Get one customer","description":"Used after the online shop has chosen the right customer and saved her\n`customer_id`. From that moment the online shop uses the id, never the\ntelephone number.\n","operationId":"getCustomer","parameters":[{"$ref":"#/components/parameters/CustomerId"}],"responses":{"200":{"description":"The customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/customers/{customer_id}/loyalty":{"get":{"tags":["Loyalty"],"summary":"Get the points of a customer","description":"The number of points that the customer has today. The website shows this\nnumber on her account page.\n\n## Tell us when you are not sure\n\nWe can no longer read the POS database, so we cannot check this number\nagainst anything. A number that is wrong, and that looks correct, is\nworse for us than no number at all.\n\nSo the answer carries `status`, and the three values mean three\ndifferent things:\n\n| `status` | Meaning | `points_balance` |\n|---|---|---|\n| `active` | She has a card and the API knows the balance. | A number. `0` is a real balance. |\n| `not_enrolled` | She has no card. | `null` |\n| `unresolved` | The API cannot calculate the balance. | `null` |\n\n**`null` and `0` are not the same thing.** `0` means she has a card with\nno points. `null` means we do not know. The website says different words\nfor each.\n\nWhen the status is `unresolved`, put the reason in `issues`. The website\nthen shows nothing instead of showing a wrong number.\n","operationId":"getCustomerLoyalty","parameters":[{"$ref":"#/components/parameters/CustomerId"}],"responses":{"200":{"description":"The points of the customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoyaltyBalance"},"examples":{"active":{"summary":"A member with points","value":{"customer_id":"cus_00412","card_number":"233000160","status":"active","points_balance":80,"issues":[],"as_of":"2026-09-23T14:05:00+03:00"}},"notEnrolled":{"summary":"A customer with no card","value":{"customer_id":"cus_00913","card_number":null,"status":"not_enrolled","points_balance":null,"issues":[],"as_of":"2026-09-23T14:05:00+03:00"}},"unresolved":{"summary":"The API cannot calculate the balance","value":{"customer_id":"cus_00777","card_number":"233000942","status":"unresolved","points_balance":null,"issues":["LOYALTY_RULES_UNCONFIRMED"],"as_of":"2026-09-23T14:05:00+03:00"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/customers/{customer_id}/loyalty/transactions":{"get":{"tags":["Loyalty"],"summary":"Get the points history of a customer","description":"Every movement of points, the newest first.\n\nSort by `occurred_at`, from the newest to the oldest. When two rows hold\nthe same time, sort them by `transaction_id`. A cursor needs a fixed\norder.\n\n**`points_delta` carries its own sign.** Points that she won are a\npositive number. Points that she used, or that ended, are a negative\nnumber. The online shop adds the values; it never guesses the sign from\nthe `type`.\n\n**A customer with no movement gets `200` and an empty list**, not `404`.\n`404` means that the customer does not exist.\n\nA movement of points is not a movement of money. A `reversed` row does\nnot mean that the shop gave money back.\n","operationId":"listCustomerLoyaltyTransactions","parameters":[{"$ref":"#/components/parameters/CustomerId"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of points movements, the newest first.","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor","server_time"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PointsTransaction"}},"next_cursor":{"$ref":"#/components/schemas/NextCursor"},"server_time":{"$ref":"#/components/schemas/ServerTime"}}},"example":{"data":[{"transaction_id":"pts_00061","customer_id":"cus_00412","type":"reversed","points_delta":-10,"sale_id":null,"reverses_transaction_id":"pts_00012","occurred_at":"2026-09-10T16:40:00+03:00"},{"transaction_id":"pts_00044","customer_id":"cus_00412","type":"expired","points_delta":-10,"sale_id":null,"reverses_transaction_id":null,"occurred_at":"2026-08-31T23:59:00+03:00"},{"transaction_id":"pts_00012","customer_id":"cus_00412","type":"earned","points_delta":100,"sale_id":"sal_0134417","reverses_transaction_id":null,"occurred_at":"2026-08-02T18:12:00+03:00"}],"next_cursor":null,"server_time":"2026-09-23T14:05:00+03:00"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/customers/{customer_id}/sales":{"get":{"tags":["History"],"summary":"Get the purchases of a customer","description":"What this customer bought in the shop, the newest first. The website\nshows this on her account page, next to the orders she made online.\n\nSort by `occurred_at`, from the newest to the oldest. When two rows hold\nthe same time, sort them by `sale_id`. A cursor needs a fixed order.\n\n## Only a link that somebody made on purpose\n\nA sale belongs in this list **only** when the POS holds a real link\nbetween the sale and this customer.\n\n**Never add a sale because the telephone number is the same, or because\nthe name on the sale looks like her name, or because the sale used the\ngeneral cash-customer code.** A sale that belongs to nobody stays with\nnobody. Showing one woman the purchases of another woman is the worst\nfault this API can make, and in a shop where the customers know each\nother it would be noticed.\n\n`customer_attribution` says how the link was made, so the website can\nsee it.\n\n**A customer with no purchase gets `200` and an empty list**, not `404`.\n","operationId":"listCustomerSales","parameters":[{"$ref":"#/components/parameters/CustomerId"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of purchases, the newest first.","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor","server_time"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SaleSummary"}},"next_cursor":{"$ref":"#/components/schemas/NextCursor"},"server_time":{"$ref":"#/components/schemas/ServerTime"}}},"example":{"data":[{"sale_id":"sal_0134417","customer_id":"cus_00412","customer_attribution":"loyalty_card","occurred_at":"2026-08-02T18:12:00+03:00","status":"completed","total":{"amount":"59.00","currency":"USD"}}],"next_cursor":null,"server_time":"2026-09-23T14:05:00+03:00"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/sales/{sale_id}":{"get":{"tags":["History"],"summary":"Get one receipt","description":"The full receipt of one sale, with its lines. The website shows this\nwhen the customer opens a purchase from her history.\n\n## Send the prices of that day\n\nThe lines carry the price that the customer paid **on that day**. Do not\ncalculate the lines again from the catalogue of today. A price that\nchanged later must not change an old receipt.\n\n## The two numbers that name a sale\n\nIn the POS a sale is named by a number **and** a type together. The same\nnumber exists with different types. `sale_id` must therefore be one\nvalue that holds both, and the API must not identify a sale by its\nnumber alone.\n\n`sale_id` is opaque: the online shop stores it and sends it back, and\nnever builds one itself.\n\n## The sums\n\n```\nline total = quantity × unit_price − line discount + line tax\nsale total = subtotal − discount_total + tax_total\n```\n\nThe online shop checks these sums. If they do not agree, it records a\nfault.\n","operationId":"getSale","parameters":[{"$ref":"#/components/parameters/SaleId"}],"responses":{"200":{"description":"The receipt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sale"},"example":{"sale_id":"sal_0134417","customer_id":"cus_00412","customer_attribution":"loyalty_card","occurred_at":"2026-08-02T18:12:00+03:00","status":"completed","location_id":"shop-kousba","source_reference":{"number":134417,"type":"POS"},"header_customer_name":"CASH CLIENT","subtotal":{"amount":"64.00","currency":"USD"},"discount_total":{"amount":"5.00","currency":"USD"},"tax_total":{"amount":"0.00","currency":"USD"},"total":{"amount":"59.00","currency":"USD"},"payment":{"method":"cash","amount_paid":{"amount":"59.00","currency":"USD"},"cash_tendered":{"amount":"100.00","currency":"USD"},"cash_change":{"amount":"41.00","currency":"USD"}},"items":[{"pos_code":"1292","colour_code":"32","size_code":"24","title":"q tishirt bag flower- croshet","quantity":2,"unit_price":{"amount":"32.00","currency":"USD"},"discount_total":{"amount":"5.00","currency":"USD"},"tax_total":{"amount":"0.00","currency":"USD"},"line_total":{"amount":"59.00","currency":"USD"}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A token that the supplier gives us. We need one token for the test\nserver and a different token for the live server. We must be able to\nchange a token without a change to the software.\n"}},"parameters":{"Since":{"name":"since","in":"query","required":false,"description":"Return the rows that changed **at this time or after it**. The\ncomparison includes the time itself.\n\nUse the `server_time` value from the last call that worked. Do not use\nthe clock of the online shop, because the two clocks are not the same.\n\nThe online shop can receive the same row two times. This is correct and\nsafe. The online shop finds the row by `pos_code`, `colour_code` and\n`size_code`, and writes it again. It is better to send a row two times\nthan to lose a row.\n\nWithout this parameter the API returns everything.\n","schema":{"type":"string","format":"date-time"}},"Cursor":{"name":"cursor","in":"query","required":false,"description":"The `next_cursor` value from the last page. Do not build this value\nyourself.\n\nThe shop sells during the time that the online shop reads the pages. A\nrow can move between pages. This is accepted. The online shop reads\neverything one time each night, and this corrects any difference.\n\n**`cursor` is stronger than `since`.** The online shop sends `since` on\nthe first call. It sends `cursor` on each call after that, and it can\nsend `since` again at the same time. When the two arrive together, use\n`cursor` and ignore `since`. The cursor already holds the `since` value\nof the first call.\n","schema":{"type":"string","maxLength":512}},"Limit":{"name":"limit","in":"query","required":false,"description":"The number of rows on one page.","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":true,"description":"A unique value that the online shop makes for each order. It is a UUID.\nThe API stores it for at least 30 days. If the same key arrives again,\nthe API returns the first result.\n","schema":{"type":"string","format":"uuid","examples":["7f3a9c02-51b4-4c2e-9a1f-2d8e6b0c4471"]}},"ExternalRef":{"name":"external_ref","in":"path","required":true,"description":"The order number of the online shop.","schema":{"type":"string","maxLength":64,"examples":["najwa-web-1042"]}},"ReservationId":{"name":"reservation_id","in":"path","required":true,"description":"The hold number. The API makes this value.","schema":{"type":"string","maxLength":64,"examples":["rsv_01J9F2K7QX"]}},"CustomerId":{"name":"customer_id","in":"path","required":true,"description":"The customer number in the POS. The API chooses this value and it never\nchanges. It must not be the telephone number, and it must not be the\ncard number, because both of those can change and both can belong to\nmore than one person.\n","schema":{"type":"string","maxLength":64,"examples":["cus_00412"]}},"SaleId":{"name":"sale_id","in":"path","required":true,"description":"The sale number. In the POS a sale is named by a number **and** a type\ntogether, so this one value must hold both. It is opaque: the online\nshop stores it and sends it back, and never builds one itself.\n","schema":{"type":"string","maxLength":64,"examples":["sal_0134417"]}},"PhoneFilter":{"name":"phone","in":"query","required":false,"description":"Find the customers with this telephone number.\n\nThe online shop sends one form only: a `+`, then the country, then the\nnumber, with no space and no other sign. In a web address the `+` is\nwritten `%2B`, so `+9613111222` is sent as `%2B9613111222`.\n\n**The API makes the comparison** against the free text in the POS.\n\nThe comparison is exact on the value, not on a part of it. With\n`card_number`, the two filters are joined with AND.\n","schema":{"type":"string","pattern":"^\\+[1-9]\\d{7,14}$","examples":["+9613111222"]}},"CardNumberFilter":{"name":"card_number","in":"query","required":false,"description":"Find the customers with this loyalty card number.\n\nIt is text, not a number. A zero at the start is part of the value and\nmust not be removed.\n","schema":{"type":"string","maxLength":64,"examples":["233000160"]}}},"schemas":{"Money":{"type":"object","description":"An amount of money. The amount is text, not a decimal number. This\nprevents rounding errors.\n\n**The currency is always `USD`.** The online shop shows Lebanese pounds\nto the customer, and it makes that calculation itself.\n","required":["amount","currency"],"properties":{"amount":{"type":"string","pattern":"^-?\\d{1,10}\\.\\d{2}$","description":"The amount, with two digits after the point.","examples":["15.00"]},"currency":{"type":"string","enum":["USD"]}},"examples":[{"amount":"15.00","currency":"USD"}]},"NextCursor":{"type":["string","null"],"description":"Send this value as `cursor` to read the next page. When it is `null`,\nthere are no more pages.\n"},"ServerTime":{"type":"string","format":"date-time","description":"The time on the server of the API. Keep this value. Send it as `since`\non the next call.\n"},"Product":{"type":"object","required":["pos_code","title","price","deleted","updated_at","variants"],"properties":{"pos_code":{"type":"string","description":"The product code in the POS. It never changes.","examples":["1292"]},"title":{"type":"string","description":"The name of the product. Remove the spaces at the start and at the\nend of the text.\n","examples":["q tishirt bag flower- croshet"]},"description":{"type":["string","null"]},"category":{"type":["string","null"],"description":"The category name, in small letters, with no space at the end. For\nexample `dress`, not `dresss` and not `dress `.\n","examples":["tshirt"]},"brand":{"type":["string","null"]},"season":{"type":["string","null"]},"price":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The normal price of one piece, **before** any discount. In the POS\nthis is `tblItemsinit.SellingPrice`.\n"},"sale_price":{"oneOf":[{"$ref":"#/components/schemas/Money"},{"type":"null"}],"description":"The price of one piece **after** the discount of the product.\n`null` if the product has no discount today.\n\n## The API must make this calculation\n\nThe POS holds the discount in a separate field. That field can hold\na percentage, or an amount, or nothing. **Do not send us that field.\nSend the price after the calculation.**\n\nWe ask for this because we cannot know the meaning of the value. In\nthe sale lines of the POS, the column `Discount1` holds `25%` on\nsome rows and `3` on other rows, and the two look the same to us.\nThe supplier knows the rule, so the supplier makes the calculation.\n\nThe website shows `price` with a line through it, and `sale_price`\nnext to it.\n"},"deleted":{"type":"boolean","default":false,"description":"`true` if the shop removed this product from the POS. A deleted\nproduct must stay in the list. Do not remove the row.\n"},"updated_at":{"type":"string","format":"date-time","description":"When this product changed. Used with `since`. Includes image bytes,\nimage metadata, ordering, colour associations and image deletions.\n"},"images":{"type":"array","description":"Image metadata, never file bytes. Optional for compatibility with\nsuppliers that have not implemented image export: omission means\nunsupported or unavailable, not that images should be deleted.\nSuppliers supporting image export must include this list on every\nproduct in both list and detail responses, including `[]` when\nthere have never been images. A temporary metadata read failure\nmust fail the request rather than return an empty list.\n\nInclude all active images and all deletion tombstones, even on a\n`since` response. IDs must be unique within the list. Sort active\nimages by position, then ID; put tombstones last, sorted by ID.\nRetain tombstones in future full and incremental product responses.\nAbsence of an ID is never a deletion signal, even in a full sync.\n\nThe importer tracks image IDs, checksums and Medusa file IDs to\navoid duplicates. Import source ordering initially; later manual\nMedusa ordering and thumbnail choices take precedence.\n","items":{"oneOf":[{"$ref":"#/components/schemas/ProductImage"},{"$ref":"#/components/schemas/DeletedProductImage"}]}},"variants":{"type":"array","description":"The colour and size combinations of this product.\n\n**An empty list is correct.** A product with no stock movement has\nno colour and no size.\n\nIn the POS these come from the stock movements\n(`tblstkdetails.colors` and `.sizes`), not from the product table.\n","items":{"$ref":"#/components/schemas/Variant"}}}},"ProductImageId":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9_-]+$","description":"Opaque, globally unique image ID assigned by the supplier. Stable across\nfile renames and content updates. Never reuse a deleted ID, even for\nanother product. The online shop must not infer identity from filenames,\nbarcodes, checksums or the image position.\n","examples":["img_1292_white_front"]},"ProductImage":{"type":"object","description":"One active image belonging to the enclosing product's `pos_code`.\nIts ID stays the same when the file changes; the checksum changes.\nJPEG, PNG and WebP are supported. If the source uses another format,\nthe supplier must export a supported format and describe those bytes.\n","required":["id","deleted","download_path","content_type","byte_size","checksum_sha256","updated_at","position","colour_code"],"properties":{"id":{"$ref":"#/components/schemas/ProductImageId"},"deleted":{"type":"boolean","const":false},"download_path":{"type":"string","pattern":"^/v1/media/[A-Za-z0-9_-]+$","description":"Root-relative path on the configured POS API HTTPS origin. Must be\n`/v1/media/{id}` using this image's ID. No credentials, query string,\nlocal filesystem path or external host. Authenticate with the same\nbearer token as catalogue requests; do not expose it to browsers.\n","examples":["/v1/media/img_1292_white_front"]},"content_type":{"type":"string","enum":["image/jpeg","image/png","image/webp"]},"byte_size":{"type":"integer","minimum":1,"description":"Size of the downloadable file in bytes.","examples":[245760]},"checksum_sha256":{"type":"string","pattern":"^[a-f0-9]{64}$","description":"Lowercase hexadecimal SHA-256 of the downloadable file bytes.\nChanges whenever those bytes change. The importer validates the\nchecksum and file type/size before storing or attaching a copy.\n"},"updated_at":{"type":"string","format":"date-time","description":"When the bytes or any image metadata last changed. Each change must\nalso update the enclosing product's updated_at.\n"},"position":{"type":"integer","minimum":0,"description":"Suggested display order, starting at zero. Lowest position, then\nlowest ID, is the suggested initial thumbnail. Medusa edits win.\n"},"colour_code":{"type":["string","null"],"description":"A colour_code present in this product's variants, or null when the\nimage applies to the whole product. Preserve leading zeros. Applies\nto every size of the colour; never match an image by barcode.\n","examples":["32",null]}}},"DeletedProductImage":{"type":"object","description":"An explicit image deletion. Retain this tombstone on its original\nproduct permanently; its ID must never be reused. Do not include a\ndownload path or file metadata. The importer may remove only the linked\nPOS-managed image, preserving manual Medusa uploads and replacements.\nA failed download or a 404 alone must never trigger removal.\n","additionalProperties":false,"required":["id","deleted","updated_at"],"properties":{"id":{"$ref":"#/components/schemas/ProductImageId"},"deleted":{"type":"boolean","const":true},"updated_at":{"type":"string","format":"date-time","description":"When the image was deleted. Also updates the parent product's\nupdated_at so the tombstone is discoverable through since.\n"}}},"Variant":{"type":"object","required":["colour_code","colour","size_code","size"],"properties":{"colour_code":{"type":"string","description":"The colour code. This is the value that does not change.","examples":["32"]},"colour":{"type":"string","description":"The colour name, for the customer to read. Correct the spelling and\nremove the spaces at the end. For example `black`, not `blacl ` and\nnot `Blck`.\n","examples":["white"]},"size_code":{"type":"string","examples":["24"]},"size":{"type":"string","examples":["m"]},"barcode":{"type":["string","null"],"description":"For information only. **Do not use this to identify a product.** Six\nbarcodes belong to more than one product.\n"}}},"StockLevel":{"type":"object","required":["pos_code","colour_code","size_code","on_hand","available","calculation_status","issues","updated_at"],"properties":{"pos_code":{"type":"string","examples":["1292"]},"colour_code":{"type":"string","examples":["32"]},"size_code":{"type":"string","examples":["24"]},"on_hand":{"type":["integer","null"],"description":"The number of pieces in the shop. Send the number that the POS\ncalculates, even if it is negative.\n","examples":[2]},"available":{"type":["integer","null"],"description":"`on_hand` minus the pieces that are on hold. **The online shop sells\nagainst this number.**\n","examples":[1]},"calculation_status":{"type":"string","enum":["verified","unverified","blocked"],"description":"Only verified stock may be sold online. Unverified quantities are\ndiagnostic only. Blocked stock has null on_hand and available.\nNever convert unknown stock to zero inside the vendor API.\n"},"issues":{"type":"array","items":{"type":"string"},"description":"Empty for verified stock; otherwise explain why it is not trusted."},"updated_at":{"type":"string","format":"date-time"}},"allOf":[{"if":{"properties":{"calculation_status":{"const":"blocked"}}},"then":{"properties":{"on_hand":{"type":"null"},"available":{"type":"null"},"issues":{"minItems":1}}},"else":{"properties":{"on_hand":{"type":"integer"},"available":{"type":"integer"}}}},{"if":{"properties":{"calculation_status":{"const":"verified"}}},"then":{"properties":{"issues":{"maxItems":0}}},"else":{"properties":{"issues":{"minItems":1}}}}]},"ReservationRequest":{"type":"object","required":["external_ref","lines"],"properties":{"external_ref":{"type":"string","maxLength":64,"description":"The basket number of the online shop. The same value returns the\nsame hold.\n","examples":["najwa-cart-5521"]},"ttl_seconds":{"type":"integer","minimum":60,"maximum":3600,"default":900,"description":"How long to hold the pieces. After this time the API releases them\nby itself.\n","examples":[900]},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/ReservationLine"}}}},"ReservationLine":{"type":"object","required":["pos_code","colour_code","size_code","quantity"],"properties":{"pos_code":{"type":"string","examples":["1292"]},"colour_code":{"type":"string","examples":["32"]},"size_code":{"type":"string","examples":["24"]},"quantity":{"type":"integer","minimum":1,"examples":[1]}}},"Reservation":{"type":"object","required":["reservation_id","external_ref","status","expires_at","lines"],"properties":{"reservation_id":{"type":"string","description":"The hold number. The API makes this value.","examples":["rsv_01J9F2K7QX"]},"external_ref":{"type":"string","examples":["najwa-cart-5521"]},"status":{"type":"string","enum":["held","released","used","expired"],"description":"`held` — the API holds the pieces now.\n`released` — the online shop released the hold.\n`used` — the hold became an order.\n`expired` — the time passed and the API released the pieces.\n"},"expires_at":{"type":"string","format":"date-time","description":"After this time the pieces go back to the shop."},"lines":{"type":"array","items":{"$ref":"#/components/schemas/ReservationLine"}}}},"OrderRequest":{"type":"object","required":["external_ref","channel","lines","payment","occurred_at"],"properties":{"external_ref":{"type":"string","maxLength":64,"description":"The order number of the online shop. It is unique.","examples":["najwa-web-1042"]},"reservation_id":{"type":["string","null"],"description":"The hold that the online shop made before the payment. The API turns\nthis hold into the sale.\n\nIf it is missing, the API takes the pieces from the stock directly.\n","examples":["rsv_01J9F2K7QX"]},"channel":{"type":"string","enum":["WEB"],"description":"Always `WEB`. This shows the staff which orders came from the website."},"occurred_at":{"type":"string","format":"date-time","description":"When the customer paid."},"customer":{"$ref":"#/components/schemas/OrderCustomer"},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/OrderLine"}},"loyalty":{"oneOf":[{"$ref":"#/components/schemas/OrderLoyaltySpend"},{"type":"null"}],"description":"The points that the customer spends on this order. `null` when she\nspends no points.\n"},"payment":{"$ref":"#/components/schemas/OrderPayment"}}},"OrderCustomer":{"type":"object","description":"The customer. Every field can be empty. If the customer has no account,\nthe online shop sends nothing here.\n","properties":{"customer_id":{"type":["string","null"],"description":"The POS ID from an explicitly verified Medusa account link. If a\nloyalty_card is also supplied, both must identify the same unique\ncustomer. Phone and name alone never attribute a sale.\n"},"phone":{"type":["string","null"],"description":"Contact information only. Never use phone equality to attribute an\norder or authorize loyalty spending.\n","examples":["+9613111222"]},"loyalty_card":{"type":["string","null"],"description":"The loyalty card number. If the online shop sends it, the API gives\nthe points to this card, with the same rule as a sale at the\ncounter.\n","examples":["233000160"]},"name":{"type":["string","null"]}}},"OrderLine":{"type":"object","required":["pos_code","colour_code","size_code","quantity","unit_price","net_price"],"properties":{"pos_code":{"type":"string","examples":["1292"]},"colour_code":{"type":"string","examples":["32"]},"size_code":{"type":"string","examples":["24"]},"quantity":{"type":"integer","minimum":1,"examples":[1]},"unit_price":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The price of one piece before the discount, as the website showed\nit. This can be different from the price in the POS, because Najwa\ncan change a price in the admin page of the online shop.\n"},"discount":{"$ref":"#/components/schemas/Discount"},"net_price":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The price of one piece that the customer paid, after the discount.\n\n**The API must use this value.** The API must not calculate the\ndiscount a second time.\n"}}},"OrderLoyaltySpend":{"type":"object","description":"The customer pays a part of this order with her loyalty points.\n\n## Why this is inside the order\n\nThe points and the sale must move together. If a separate call removed\nthe points, and the order then failed, the customer would lose her\npoints and receive nothing. **The API must remove the points and write\nthe order in one action, or do neither.**\n\n## The API decides the value, not the online shop\n\nThe supplier owns the rule that turns points into money, the same as\nthe rule that gives points. The online shop reads the value from\n`GET /customers/{customer_id}/loyalty` and shows it to the customer.\n\nThe online shop then sends back the value it showed, in\n`expected_value`. **The API compares it with its own calculation.** If\nthe two do not agree, the API returns `409 loyalty_value_mismatch` and\nwrites nothing. This stops the customer seeing one price and paying\nanother.\n\n## The points may not be there\n\nThe customer can spend her points at the counter at the same minute. We\nhold no balance that we can trust. If the points are not there, the API\nreturns `409 insufficient_points`.\n","required":["points_to_spend","expected_value"],"properties":{"points_to_spend":{"type":"integer","minimum":1,"description":"How many points the customer spends.","examples":[200]},"expected_value":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The money that the online shop took off the order for these points.\n"}}},"Discount":{"type":"object","description":"A discount on one line. The type is always given.\n\nIn the POS the column `Discount1` holds `25%` on some rows and `3` on\nother rows, and nothing tells us which is which. This object removes\nthat problem.\n","required":["type","value"],"properties":{"type":{"type":"string","enum":["PCT","AMT"],"description":"`PCT` = a percentage. `AMT` = an amount of money."},"value":{"type":"string","pattern":"^\\d{1,10}(\\.\\d{1,2})?$","description":"For `PCT`, a number from 0 to 100, for example `25`.\nFor `AMT`, an amount, for example `5.00`.\n","examples":["25"]}}},"OrderPayment":{"type":"object","required":["method","amount"],"properties":{"method":{"type":"string","enum":["card","whish","cash_on_delivery"],"description":"`card` = a bank card. `whish` = the Whish Money service.\n`cash_on_delivery` = the customer pays when the goods arrive.\n"},"amount":{"$ref":"#/components/schemas/Money"}}},"Order":{"type":"object","required":["external_ref","pos_number","status","total","created_at"],"properties":{"external_ref":{"type":"string","examples":["najwa-web-1042"]},"pos_number":{"type":"integer","description":"The sale number in the POS. The API chooses this number. The staff\nuse it to find the sale.\n","examples":[134417]},"status":{"type":"string","enum":["recorded","cancelled"]},"total":{"$ref":"#/components/schemas/Money"},"created_at":{"type":"string","format":"date-time"},"cancelled_at":{"type":["string","null"],"format":"date-time"},"points_awarded":{"type":["integer","null"],"description":"The loyalty points that this order gave to the customer. `null` if\nthe order had no loyalty card. The website shows this number.\n","examples":[8]},"points_spent":{"type":["integer","null"],"description":"The loyalty points that the customer spent on this order. `null` if\nshe spent none.\n","examples":[200]},"points_value":{"oneOf":[{"$ref":"#/components/schemas/Money"},{"type":"null"}],"description":"The money value of `points_spent`, as the API calculated it. `null`\nif she spent no points.\n"}}},"Customer":{"type":"object","description":"A customer in the POS.\n\n**Every field except `customer_id` can be empty.** The POS customer\ntable is filled by hand at the counter, so a row can hold a telephone\nnumber and nothing else.\n\nThere is no email address here, because the POS customer table has no\nemail column. The email address of the customer belongs to the online\nshop.\n","required":["customer_id","updated_at"],"properties":{"customer_id":{"type":"string","description":"The customer number in the POS. It never changes. The online shop\nsaves this value and uses it for every later call.\n","examples":["cus_00412"]},"name":{"type":["string","null"],"examples":["Maya Issa"]},"phone":{"type":["string","null"],"description":"The telephone number, in the same form the online shop sends: a `+`,\nthen the country, then the number.\n\n**This value does not identify the customer.** More than one\ncustomer can have the same number.\n","examples":["+9613111222"]},"card_number":{"type":["string","null"],"description":"The loyalty card number, as text. A zero at the start is part of the\nvalue. `null` if she has no card.\n","examples":["233000160"]},"address":{"type":["string","null"],"examples":["Kousba, Koura"]},"updated_at":{"type":"string","format":"date-time"}}},"LoyaltyBalance":{"type":"object","description":"The points of one customer today.","required":["customer_id","status","points_balance","issues","as_of"],"properties":{"customer_id":{"type":"string","examples":["cus_00412"]},"card_number":{"type":["string","null"],"examples":["233000160"]},"status":{"type":"string","enum":["active","not_enrolled","unresolved"],"description":"`active` — she has a card and the API knows the balance.\n`not_enrolled` — she has no card.\n`unresolved` — the API cannot calculate the balance. Say why in\n`issues`.\n"},"points_balance":{"type":["integer","null"],"description":"The points she has now.\n\n**A number only when `status` is `active`.** `0` is a real balance:\nit means a card with no points.\n\n**`null` for `not_enrolled` and for `unresolved`**, and the two mean\ndifferent things. Never send `0` to mean \"we do not know\".\n","examples":[80]},"issues":{"type":"array","description":"Why the API could not give a balance. Empty when there is no\nproblem. The website shows nothing rather than a number it cannot\ntrust.\n","items":{"type":"string","enum":["LOYALTY_RULES_UNCONFIRMED","CARD_NOT_FOUND","CARD_SHARED_BY_SEVERAL_CUSTOMERS","HISTORY_INCOMPLETE"]}},"points_value":{"oneOf":[{"$ref":"#/components/schemas/Money"},{"type":"null"}],"description":"The money value of `points_balance` today. The website shows this\nnumber before the customer pays, so she knows what her points are\nworth.\n\n**The API calculates this. The online shop never calculates it.**\nThe supplier owns the rule.\n\n`null` whenever `points_balance` is `null`.\n","examples":[{"amount":"5.00","currency":"USD"}]},"as_of":{"type":"string","format":"date-time","description":"The time of this calculation."}}},"PointsTransaction":{"type":"object","description":"One movement of points.","required":["transaction_id","customer_id","type","points_delta","occurred_at"],"properties":{"transaction_id":{"type":"string","examples":["pts_00012"]},"customer_id":{"type":"string","examples":["cus_00412"]},"type":{"type":"string","enum":["earned","redeemed","expired","reversed"],"description":"`earned` — a purchase gave her points.\n`redeemed` — she used points.\n`expired` — points ended.\n`reversed` — this row cancels all or a part of an earlier row.\n"},"points_delta":{"type":"integer","description":"The change, **with its sign**. Positive when she wins points,\nnegative when she loses them.\n\nA `reversed` row can be positive or negative: it carries the\nopposite sign of the row that it cancels.\n\nThe online shop adds these values. It never decides the sign from\nthe `type`.\n","examples":[100]},"sale_id":{"type":["string","null"],"description":"The sale that caused this movement, when there is one. `null` for a\nmovement that has no sale, for example `expired`.\n","examples":["sal_0134417"]},"reverses_transaction_id":{"type":["string","null"],"description":"For a `reversed` row, the row that it cancels. It belongs to the\nsame customer. `null` for every other type.\n"},"occurred_at":{"type":"string","format":"date-time"}}},"SaleAttribution":{"type":"string","enum":["loyalty_card","verified_link","none"],"description":"How the sale was joined to the customer.\n\n`loyalty_card` — the customer used her card on the sale.\n`verified_link` — a person joined the sale to the customer on purpose.\n`none` — the sale belongs to nobody.\n\n**A sale with `none` appears in no customer's history.** A name on the\nsale, or the general cash-customer code, is not an attribution.\n"},"SaleSummary":{"type":"object","description":"One purchase, as it appears in a list.","required":["sale_id","customer_attribution","occurred_at","status","total"],"properties":{"sale_id":{"type":"string","examples":["sal_0134417"]},"customer_id":{"type":["string","null"],"description":"The customer of this sale. `null` when it belongs to nobody.","examples":["cus_00412"]},"customer_attribution":{"$ref":"#/components/schemas/SaleAttribution"},"occurred_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["completed"],"description":"Only a finished sale appears here. A return and a cancellation are\nnot sales, and this API does not carry them.\n"},"total":{"$ref":"#/components/schemas/Money"}}},"Sale":{"type":"object","description":"The full receipt of one sale.\n\n**The prices are the prices of that day.** Do not calculate them again\nfrom the catalogue of today.\n\n## The sums\n\n```\nline_total = quantity × unit_price − discount_total + tax_total\ntotal      = subtotal − discount_total + tax_total\n```\n\n⚠️ **One currency only.** A sale that the staff took partly in dollars\nand partly in Lebanese pounds cannot be written in this shape. If those\nsales exist in the POS, tell us, and we change this part of the\ncontract.\n","required":["sale_id","customer_attribution","occurred_at","status","source_reference","subtotal","discount_total","tax_total","total","items"],"properties":{"sale_id":{"type":"string","examples":["sal_0134417"]},"customer_id":{"type":["string","null"],"examples":["cus_00412"]},"customer_attribution":{"$ref":"#/components/schemas/SaleAttribution"},"occurred_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["completed"]},"location_id":{"type":["string","null"],"description":"The shop. There is one shop today.","examples":["shop-kousba"]},"source_reference":{"type":"object","description":"The two values that name this sale in the POS. Both are needed: the\nsame number exists with different types.\n","required":["number","type"],"properties":{"number":{"type":"integer","examples":[134417]},"type":{"type":"string","examples":["POS"]}}},"header_customer_name":{"type":["string","null"],"description":"The name written on the sale at the counter. It is often the general\ncash-customer name. **It does not identify the customer** — use\n`customer_id` for that.\n","examples":["CASH CLIENT"]},"subtotal":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The sum of the lines, before the discounts and the tax."},"discount_total":{"$ref":"#/components/schemas/Money"},"tax_total":{"$ref":"#/components/schemas/Money"},"total":{"$ref":"#/components/schemas/Money"},"payment":{"oneOf":[{"$ref":"#/components/schemas/SalePayment"},{"type":"null"}]},"items":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/SaleLine"}}}},"SaleLine":{"type":"object","description":"One line of a receipt, with the values of that day.\n","required":["title","quantity","unit_price","line_total"],"properties":{"pos_code":{"type":["string","null"],"description":"The product code of that day. It can name a product that the shop no\nlonger sells. `null` if the POS did not keep it.\n","examples":["1292"]},"colour_code":{"type":["string","null"],"examples":["32"]},"size_code":{"type":["string","null"],"examples":["24"]},"title":{"type":"string","description":"The name of the product **as it was on that day**, not the name in\nthe catalogue today.\n","examples":["q tishirt bag flower- croshet"]},"quantity":{"type":"integer","minimum":1,"examples":[2]},"unit_price":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The price of one piece on that day, before the discount."},"discount_total":{"$ref":"#/components/schemas/Money"},"tax_total":{"$ref":"#/components/schemas/Money"},"line_total":{"$ref":"#/components/schemas/Money"}}},"SalePayment":{"type":"object","description":"How the customer paid. There is no card number and no secret value here,\nand there must never be one.\n","required":["method","amount_paid"],"properties":{"method":{"type":"string","enum":["cash","card","whish","other"],"description":"How she paid at the counter. This list is not the same as the list\nfor an online order, because the counter takes cash and the website\ndoes not.\n"},"amount_paid":{"$ref":"#/components/schemas/Money"},"cash_tendered":{"oneOf":[{"$ref":"#/components/schemas/Money"},{"type":"null"}],"description":"The money the customer gave. `null` when she did not pay with cash.\n"},"cash_change":{"oneOf":[{"$ref":"#/components/schemas/Money"},{"type":"null"}],"description":"The money the staff gave back. **This is change, not a refund.**\n`null` when she did not pay with cash.\n"}}},"ExchangeRequest":{"type":"object","required":["exchange_ref","occurred_at","inbound_lines","outbound_lines","difference"],"properties":{"exchange_ref":{"type":"string","maxLength":64,"description":"The exchange number of the online shop. It is unique. It is not the\norder number.\n","examples":["najwa-web-1042-x1"]},"occurred_at":{"type":"string","format":"date-time","description":"When the customer came to the shop."},"reason":{"type":["string","null"],"maxLength":500,"examples":["wrong size"]},"inbound_lines":{"type":"array","minItems":1,"description":"The pieces that come back. **The API adds them to the stock.**\n","items":{"$ref":"#/components/schemas/ExchangeLine"}},"outbound_lines":{"type":"array","minItems":1,"description":"The pieces that go out. **The API removes them from the stock.**\n","items":{"$ref":"#/components/schemas/ExchangeLine"}},"difference":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The money that the customer pays on top. `0.00` when the two sides\nhave the same value.\n\n**It is never below zero.** The shop gives no money back.\n"},"payment":{"oneOf":[{"$ref":"#/components/schemas/OrderPayment"},{"type":"null"}],"description":"How the customer paid the difference. `null` when `difference` is\n`0.00`.\n"}}},"ExchangeLine":{"type":"object","required":["pos_code","colour_code","size_code","quantity","net_price"],"properties":{"pos_code":{"type":"string","examples":["1292"]},"colour_code":{"type":"string","examples":["32"]},"size_code":{"type":"string","examples":["24"]},"quantity":{"type":"integer","minimum":1,"examples":[1]},"net_price":{"allOf":[{"$ref":"#/components/schemas/Money"}],"description":"The price of one piece that the customer paid. On an inbound line\nthis is the price of the first sale, not the price of today.\n"}}},"Exchange":{"type":"object","required":["exchange_ref","external_ref","pos_number","status","difference","created_at"],"properties":{"exchange_ref":{"type":"string","examples":["najwa-web-1042-x1"]},"external_ref":{"type":"string","description":"The order of the first sale.","examples":["najwa-web-1042"]},"pos_number":{"type":"integer","description":"The document number in the POS. The API chooses it. The staff use it\nto find the exchange.\n","examples":[134512]},"status":{"type":"string","enum":["recorded"]},"difference":{"$ref":"#/components/schemas/Money"},"created_at":{"type":"string","format":"date-time"},"points_adjusted":{"type":["integer","null"],"description":"The change to the loyalty points of the customer, with its sign.\n`null` if the first order had no loyalty card.\n","examples":[2]}}},"ErrorResponse":{"type":"object","description":"## Which errors the online shop sends again\n\n| Answer | Send again? |\n|---|---|\n| `500`, or no answer, or a timeout | **Yes.** Wait, then send again with the same `Idempotency-Key`. |\n| `409 insufficient_stock` | **No.** Tell the customer. |\n| `409 insufficient_points` | **No.** Tell the customer. Read her points again. |\n| `409 loyalty_value_mismatch` | **No.** Read her points again and show the new price. |\n| `409 refund_not_supported` | **No.** Correct the lines of the exchange. |\n| `409 reservation_expired` | **No.** Make a new hold first. |\n| `409 idempotency_key_reuse` | **No.** This is a fault in the online shop. |\n| `400`, `422` | **No.** This is a fault in the online shop. |\n| `401` | **No.** The token is wrong. |\n","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"A fixed value. The software of the online shop reads this value\nand decides what to do. **Do not change these values.**\n","enum":["bad_request","unauthorized","not_found","unknown_customer","unknown_sale","unknown_pos_code","unknown_variant","insufficient_stock","reservation_expired","reservation_already_used","idempotency_key_reuse","external_ref_exists","order_not_cancellable","order_not_exchangeable","exchange_ref_exists","refund_not_supported","insufficient_points","loyalty_value_mismatch","internal_error"]},"message":{"type":"string","description":"A short text for a person to read. The software does not read\nthis text.\n"},"details":{"type":"object","additionalProperties":true,"description":"Extra information. For example, which line has the problem, and\nhow many pieces are available.\n"}}}},"examples":[{"error":{"code":"insufficient_stock","message":"Only 0 pieces of 1292 / colour 32 / size 24 are available.","details":{"pos_code":"1292","colour_code":"32","size_code":"24","available":0}}}]}},"responses":{"BadRequest":{"description":"The request is not correct.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"Unauthorized":{"description":"The token is missing, or it is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFound":{"description":"The API cannot find this item.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"Unprocessable":{"description":"The API understands the request, but it cannot use the data. For example\n`unknown_pos_code`, or a colour and a size that do not exist together.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ServerError":{"description":"A fault in the API. **The online shop sends the request again** after a\nshort time, with the same `Idempotency-Key`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}