This guide is for the team connecting an ERP (NetSuite, Odoo, SAP Business One, Dynamics, or any other) to one factory's MerchandiserOS workspace. The full list of fields and answers is in the technical reference, erp-api-openapi.yaml (OpenAPI 3). If this guide and the technical reference ever disagree, tell us: the reference is checked against the running API, and this guide is corrected to match.
1. What this is
MerchandiserOS runs the factory's work: orders, styles, materials, purchase requests, production and shipping. Your ERP keeps the books. The two share facts in both directions.
There are two ways to connect, and the same rules apply to both:
- File exchange, no programming. In Settings → Integrations → ERP, the factory exports files for your ERP and pastes back what the ERP answers (PO numbers, payment dates, item codes).
- This API. Your ERP calls
https://<workspace>.merchandiseros.online/api/v1/...directly.
Whichever way you use, nothing your ERP sends changes a factory record on its own. It goes to a review list, and a person approves or rejects it (section 7).
2. Getting a login
The factory owner creates the login. You cannot create one yourself.
- In MerchandiserOS: Settings → Integrations → ERP → "ERP API".
- The owner types what the login is for (for example "Odoo — purchasing") and creates it.
- The screen shows a username (like
erp-7f3k2q9a) and a password. The password is shown once and can never be shown again. Copy both, then store them in your ERP's secret store.
What this login can and cannot do:
- It can only call
/api/v1. It can never open the app's screens, and it is not a person's account. - If the owner turns the login off, or gives it a new password, every token it holds stops working at once. If you lose the password, the owner can issue a new one.
- Five wrong passwords in a row lock the login for 15 minutes. A correct password does not unlock it early.
- A token only works on the web address of the workspace that issued it.
3. Sign in
curl -X POST https://<workspace>.merchandiseros.online/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "erp-7f3k2q9a", "password": "<the password>"}'
Answer:
{
"token": "mosi.ZmFjdG9yeS0x.q3...",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-09-25T11:04:12.000Z"
}
- The token lasts one hour. Read
expires_atand sign in again a few minutes before it passes. Don't wait for a 401. - Send the token on every other call:
Authorization: Bearer <token>. - Every sign-in failure says the same thing ("Wrong username or password."), including a locked or turned-off login, so the answer never reveals whether a username exists.
- Sign-in is limited to 20 attempts per IP address and 10 per username in any 10 minutes. Past that, you get 429 with a
Retry-Afterheader.
4. The rules that never bend
The money line. No amount is ever read. Each endpoint copies only the fields it names. If you send amount, total, balance, value or any other unlisted field, it is ignored and never stored. The one number read as a price is a material's unit price (POST /erp/material-prices). That is a costing estimate, never a payable, and it is never booked.
What we SEND carries prices on exactly two documents, each a dated decision:
- a sales order carries the buyer's agreed selling price (since 2026-10-08), so your ERP can raise and invoice the sales order;
- an issued purchase order carries each line's agreed unit price (since 2026-10-09), so your ERP can match the supplier's invoice against it.
Both are agreements, not ledger entries. No document carries a total or an amount — you work those out. A purchase request and a goods receipt carry no price.
We match on OUR references. Name the factory's record in the factory's own terms:
| Record | Our reference |
|---|---|
| Order | order number, e.g. ORD-1042 |
| Purchase request | request number, e.g. PR-1042, or the external_key of the document we sent |
| Material | material code, e.g. MAT-201 |
| Shipment | our shipment id (a number we gave you) |
Your own codes (your item code, vendor code, document number) are shown to people, but they are never used to find a record.
Send YOUR permanent internal id for every record of yours. Use the id your system generates and nobody can edit, not a document number:
| Your record | Field |
|---|---|
| Purchase order | erp_po_id (required) |
| Invoice / credit note | erp_invoice_id (required) |
| Claim / chargeback / debit note | erp_claim_id (required) |
| Code-list entry | erp_id (required) |
| A document you acknowledge | erp_id (optional, strongly recommended) |
Document numbers (erp_po_number, doc_number, erp_ref) are display only and may change. A record deleted and re-created in your ERP gets a new id, so it is a new record to us.
Cancelling on your side cancels nothing here. A PO you cancel is shown to a person. Nothing in MerchandiserOS is cancelled because of it.
Idempotency-Key on every write. Every POST, PUT and DELETE (except sign-in) must carry an Idempotency-Key header: 8–128 characters from letters, digits and . _ : -.
- If you retry with the same key, you get the first answer back, with the header
idempotent-replayed: true, and nothing is written twice. - Keys are kept for 24 hours for each login.
- Only an accepted answer (below 400) is stored. A refusal (4xx) is not, so after you fix a refused request you may resend it with the same key.
- The same key on a different method or path is refused: 409
idempotency_key_reused. - A missing or malformed key is refused: 400
idempotency_key_required.
429 always carries Retry-After. Wait that many seconds, then retry.
5. Pull documents
MerchandiserOS queues documents for your ERP: sales orders (sales_order), and for buying, depending on who issues the purchase order (the workspace's "Purchase orders issued by" setting, po_mode in GET /erp/settings):
po_mode | Who issues the PO | Buying documents you receive |
|---|---|---|
operational (default) | MerchandiserOS | purchase_order when a PO is issued; goods_receipt for each receipt once its QC result is final; supplier_return for goods sent back. Since 2026-10-09 no po_request is queued in this mode — neither on the API nor in the CSV "Export for ERP"; any queued before the switch stay in the list |
erp_issued | Your ERP | po_request when a request is approved (the demand signal). Since 2026-10-10 also goods_receipt for each receipt and supplier_return for each return — MerchandiserOS still records every receipt (dye lots, incoming QC, returns), against the purchase orders you send on POST /erp/purchase-orders (6.15), and each one carries your erp_po_id and erp_line_id. Never a purchase_order for a PO you issued |
Who issues purchase orders is set once per factory by the owner (Settings → Integrations → ERP, "Who issues purchase orders"), with a reason that is kept. It cannot be switched while any open purchase order of the other origin exists — two issuers at once would mean duplicate orders to suppliers. Each purchase order keeps behaving as its own issuer says, whatever the setting says later: a PO MerchandiserOS issued keeps getting revisions of its copy; a PO you issued is never sent back to you.
In both modes you also receive a supplier_request for each supplier, so your accounts system approves it (see "A supplier for your approval" below and section 6.14).
Other types exist in the list of document types.
curl -H "Authorization: Bearer $TOKEN" \
"https://<workspace>.merchandiseros.online/api/v1/erp/documents?limit=50"
- By default the list holds documents not yet acknowledged (
pending,exported). Filter with?status=and?type=. - The list is oldest first and cursor-paged. Pass
next_cursorback as?cursor=until it isnull.limitis 1–200 (default 50). ?modified_since=2026-09-25T00:00:00Zreturns only documents changed since then.- Fetch one document:
GET /erp/documents/{external_key}. The key looks likepurchase_request:12:po_request:1, so URL-encode it. - The
payloadcarries quantities, units, dates, codes and ids. A purchase request never carries a price. A sales order also carries:buyer_idandstyle_id: OUR permanent ids for the buyer and the style. Bind your customer and item on these, never on a name.nullwhen the order links none.lines: the colour × size breakdown,[{"colour": "Navy", "size": "M", "qty": 600}], incounting_unit. Empty when no breakdown is recorded yet, withlines_notesaying so. If the lines do not add up toqty, both are sent as they stand andlines_notesays so. Nothing is corrected for you.price: the buyer's agreed selling price,{"unit_price": 1.85, "currency": "USD", "per": "pair"}, percounting_unit, in the order's own currency (never converted).nullwhen no agreed price is recorded, withprice_notesaying why. Never 0 in its place, never an estimate. A real price of 0 is sent as 0. If the order's counting unit (pieces or pairs) is not recorded,priceisnulltoo: a price per an assumed unit would be wrong by 2× for pairs. An order with no currency chosen is stored in USD, socurrencyisUSDunless someone picked another.- Known gap: a price change after the ERP linked the order is not re-sent yet — the ERP keeps the first price. The document's
revisionandupdated_atdo not move when the price, quantity or breakdown is amended, somodified_sinceand/erp/changesdo not show it. - Only the sales order carries these. Another document on the same order (an agent's
commission) does not carry the price, the lines or the ids. terms:payment_termsandpayment_terms_erpincotermandincoterm_erptolerance,partial_shipment,lc,latest_ship_date
The _erp fields hold your code, but only when our value matches a code (or name) you sent us exactly; otherwise they are null (section 6.11). If the record has moved on (closed, or no longer approved), payload is null and payload_note says why.
A purchase order (purchase_order). Sent when MerchandiserOS issues the PO, for a locked, read-only copy your accountant matches the supplier's invoice against. Nobody types it in your ERP; it is changed in MerchandiserOS.
{"po_ref": "PO-1042", "po_id": 1042, "version": 2, "version_effective_at": "2026-10-12T08:30:00Z",
"status": "Issued", "currency": "USD", "required_by": "2026-11-20",
"supplier": {"id": 12, "code": "SUP-112", "name": "Delta Yarns", "country": "EG", "payment_terms": "60 days"},
"lines": [{"po_line_id": 3001, "line_no": 1, "material_id": 201, "material_code": "MAT-201",
"material_name": "Cotton yarn 30/1", "colour": "Navy", "size": null, "qty": 1200,
"unit": "kg", "unit_price": 3.2, "receipt_tolerance_pct": 10}]}
- Bind on OUR ids —
po_id,supplier.id,material_id,po_line_id— never on a name or a code. - No totals: quantity ×
unit_priceis yours to work out. versionis the version of the order in force when you fetch it (since 2026-10-10). After issue, a price, a quantity or the required-by date changes only as a NEW version of the order — drafted with a reason, approved when it is over the factory's approval limit, then put in force.version_effective_atis when it took effect (null, withversion_effective_at_note, for a PO sent before versions were kept). The printed order reads "PO-1042 v2". The supplier never changes on an issued PO — the factory cancels what is still to come and raises a new PO.- A change arrives as a new revision. When a new version of the PO takes effect, an issued PO is cancelled, or it is closed short, the next
revisionis queued (a newexternal_key, e.g.purchase_order:1042:purchase_order:2). Every payload is built from the PO as it stands when you fetch it, so the newest revision is the current state.statusisIssued,Acknowledged,Dispatched,Received,Closed short(the supplier should not invoice the shortfall) orCancelled(withcancelled_reason). - A missing fact is
nullwith a note: a line with no library material hasmaterial_id: null, itsdescriptionandmaterial_note; a single-material PO has one line withpo_line_id: nullandpo_line_note.
A goods receipt (goods_receipt). One per receipt saved in MerchandiserOS, queued once its incoming QC result is final (since 2026-10-10). Each material type is set to need QC before it counts as stock, or not (ours: fabric and yarn yes, trims no — the factory can change it). A type that needs no QC is final the moment it is received, so its receipt comes at once; one that needs QC waits as "awaiting QC" and comes only after the QC result is recorded — you never receive goods that may still be rejected. No money. A final result never changes, so a receipt has one revision.
{"grn_ref": "GRN-2207", "grn_id": 2207, "po_ref": "PO-1042", "po_id": 1042, "po_line_id": 3001,
"material_id": 201, "material_code": "MAT-201", "material_name": "Cotton yarn 30/1", "qty": 600,
"accepted_qty": 580, "rejected_qty": 20, "rejection_reason": "Shade outside band",
"inspection_ref": "MINS-1004", "qc_state": "final", "qc_basis": "from the incoming inspection",
"unit": "kg", "dye_lot": "DL-77", "shade_band": null,
"shade_band_note": "No shade band was recorded on this receipt.", "received_date": "2026-11-18",
"accepted_date": "2026-11-21", "supplier_id": 12}
- Post the receipt on
accepted_date— the date the goods were accepted into stock. For a material that needs incoming QC it is the day the QC result was decided, which can be in a later month thanreceived_date(QC may be passed after a month is closed; the goods enter stock in the month they are accepted). For one that needs no QC it equalsreceived_date. qtyis what physically arrived; bookaccepted_qtyinto stock andrejected_qtyinto a rejected warehouse.accepted_qty + rejected_qty = qty.inspection_refisnullwhen the result was recorded by hand or no inspection was required. A receipt recorded before acceptance was kept reads all accepted, with aqc_notesaying so.
- Receiving is never blocked in MerchandiserOS — goods are recorded when they arrive. If we never sent you a copy of the receipt's purchase order,
po_notesays so, e.g. "GRN against PO-1042 which was never issued — no copy to receive against." Skip the receipt with that reason; never create a PO from a receipt. - Receipts against a purchase order YOU issued (6.15) also carry
issued_by: "erp",erp_po_idanderp_po_number(yours) anderp_line_id(your id for the line). Match the receipt to your PO onerp_po_id+erp_line_id— never on a number or a name. On a purchase order MerchandiserOS issued they readissued_by: "mos"andnull. Asupplier_returncarries the same three fields.
A supplier for your approval (supplier_request). Since 2026-10-10 your accounts system owns the supplier list. One is queued when a supplier is added in MerchandiserOS (only while your ERP is connected), and a new revision when its name, country or payment terms change. Answer it on POST /erp/supplier-status (6.14). Until you approve it, MerchandiserOS will not issue a priced purchase order to that supplier. It never carries bank details — they live only in your accounts system — and never money.
{"supplier_ref": "SUP-142", "mos_supplier_id": 142, "name": "Delta Weaving", "country": "EG",
"category": "Fabric", "payment_terms": "30 days", "contact": "sales@deltaweaving.eg",
"address": null, "erp_vendor_id": null, "erp_vendor_code": null,
"contact_note": "MerchandiserOS keeps one free-text contact (a name, an email or a phone, as typed) — sent as recorded.",
"address_note": "MerchandiserOS does not hold the supplier's address — take it from the supplier directly."}
- Bind on
mos_supplier_id(our permanent id).erp_vendor_id/erp_vendor_codeare filled once a person here has confirmed the link to your vendor. contactis a free-text field, sent exactly as typed in MerchandiserOS — we do not check or clean it. Bank details must never be typed there: they belong only in your accounts system. If one ever arrives incontact, do not store it as a bank account; tell the factory to remove it.- Create the vendor only from a
supplier_request; never rename a vendor you were not sent.
A return to the supplier (supplier_return). One per return note (SRN-…) — goods sent back against one receipt. No money: raise the supplier's debit note from it (in ERPNext, a Purchase Receipt return against the receipt). Returns are never edited: one revision each.
{"return_ref": "SRN-1001", "return_id": 1001, "grn_ref": "GRN-2207", "grn_id": 2207,
"po_ref": "PO-1042", "po_id": 1042, "po_line_id": 3001, "material_id": 201,
"material_code": "MAT-201", "material_name": "Cotton yarn 30/1", "qty": 20, "unit": "kg",
"dye_lot": "DL-77", "from_rejected": true, "replace_expected": true,
"reason": "Shade outside band", "return_date": "2026-11-21", "supplier_id": 12}
from_rejected: true— the goods were rejected at incoming QC and were never in stock (take them out of the rejected warehouse).false— accepted goods found bad later; they leave stock onreturn_date.replace_expected: true— the supplier owes them again: the purchase order waits for the replacement (a PO already closed as fully received is reopened, and its next revision says so).
Acknowledge a document. Acknowledging is a separate call from any status update:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: ack-PR-1042-1" \
-H "Content-Type: application/json" \
"https://<workspace>.merchandiseros.online/api/v1/erp/documents/purchase_request%3A12%3Apo_request%3A1/ack" \
-d '{"status": "linked", "erp_id": "88213", "erp_ref": "PO-2026-0457"}'
statusisacknowledged(you have it),linked(you created your record from it) orrefused(you could not take it — see below).- This call writes at once, because it changes no order, request or material.
- A document only moves forward. A repeat returns the same answer with
changed: false. - Once an
erp_idis stored, a differenterp_idfor the same document is refused with 409. A document is never re-bound to another record of yours.
Refuse a document. When your ERP cannot take a document, say so and why — do not just leave it, or MerchandiserOS keeps calling it fine:
{"status": "refused", "reason": "Supplier 12 has no account in MOS Business yet."}
reasonis required, at most 500 characters (a longer one is refused with 422, never cut).- The document becomes
error, witherror: "Refused by the ERP: <reason>". It leaves the open list, and the reason shows in MerchandiserOS wherever the document's status shows. - A
linkeddocument cannot be refused (409). - Fixed your side? Send
acknowledgedorlinked— it is accepted and clears the reason. - Optionally add a short machine
codebeside the reason — 1–40 lower-case letters or underscores, e.g.period_closed,validation,duplicate. Anything else is refused with 422. It is stored next to the reason and returned asrefusal_code:
{"status": "refused", "reason": "The October period is closed.", "code": "period_closed"}
period_closedhas a meaning of its own: "my books are closed for the month this document belongs to." MerchandiserOS says so on the record itself — on the receipt, "Accounts refused this receipt: their books are closed for October 2026 — ask them to post it in the open month", with your reason beside it. To stop it happening at all, tell us your books are closed (section 6.13): we then refuse new receipts, issues and returns dated in a closed month.
Prove the values, not just the count — agreed_values and content_hash. Every document you fetch (the list and the single GET) carries:
agreed_values— only the facts both sides must agree on, in a fixed shape per document type. It never holds a status, a date or time, a name, a note or a code that can be renamed — so a purchase order moving from Issued to Received, or an order shipping, is never a disagreement. It is built from our records, so a document whosepayloadis nownull(a closed order) still has it.content_hash— the SHA-256, lower-case hex, ofagreed_valueswritten as canonical JSON: object keys sorted at every level, arrays in their order, no spaces, UTF-8, numbers as JSON writes them (1200, not1200.0;3.2, not3.20). We serveagreed_valuesin exactly that form. Both arenullfor a type with no shape yet.
doc_type | agreed_values |
|---|---|
sales_order | {"ref", "lines": [{"line_id", "item_id", "qty", "unit", "unit_price", "currency"}]} — ref is our order number; one line per colour × size row (line_id is our breakdown line id), or one line with line_id: null and the order quantity when no breakdown is recorded; item_id is our style id; unit is the counting unit (pc / pair); unit_price is the agreed price, null when none is recorded or the counting unit is not |
purchase_order | {"ref", "version", "lines": [{"line_id", "item_id", "qty", "unit", "unit_price", "currency"}]} — ref is the PO number; line_id is our PO line id (null on a single-material PO); item_id is our material id; unit_price null when no price is recorded; version is the version in force (1 until a change takes effect) — the same number as the copy's version |
goods_receipt | {"ref", "po_ref", "lines": [{"line_id", "item_id", "qty", "accepted_qty", "rejected_qty", "unit", "dye_lot"}]} — one line: the PO line received against, our material id, the received quantity, the accepted and rejected quantities, its unit, the dye lot (null when not recorded). A receipt recorded before acceptance was kept has no accepted_qty / rejected_qty keys (it is all accepted) |
supplier_return | {"ref", "grn_ref", "po_ref", "lines": [{"line_id", "item_id", "qty", "unit", "dye_lot"}]} — ref is the return number; one line: the PO line, our material id, the quantity returned, its unit, the dye lot. No money |
supplier_request | {"ref", "name", "country"} — our supplier code, its name and country code. For a supplier the name is the fact you approve, so this is the one shape that carries a name. No bank details, no money |
po_request (from a purchase request) | {"ref", "lines": [{"line_id", "item_id", "qty", "unit"}]} — item_id null for a line with no library material |
po_request (from a buyer-side order), commission | {"ref", "qty", "unit"} |
| any other type | null — not provided yet; acknowledge without a hash (it reads "counts only") |
After your ERP has stored the document, read back what it stored, rebuild agreed_values from those stored values in the same shape (store our line_id and item_id beside your lines — they are what lets you rebuild it), hash it, and send that hash on the ack as verified_content_hash.
Worked example. Your ERP stored PO-1042 with one line — our line 3001, our material 201, 1,200 kg at 3.20 USD:
{"ref": "PO-1042", "lines": [{"line_id": 3001, "item_id": 201, "qty": 1200, "unit": "kg", "unit_price": 3.2, "currency": "USD"}]}
Canonical JSON (the exact bytes to hash):
{"lines":[{"currency":"USD","item_id":201,"line_id":3001,"qty":1200,"unit":"kg","unit_price":3.2}],"ref":"PO-1042"}
SHA-256: 8ef889b0c3359fbf0146b39d58606235ef0d99d99b7c3c0cbcd3f491db751aff
import hashlib, json
def _norm(v):
# Many ERPs store quantities as decimals: 1200.0 must be written 1200, as JSON does.
if isinstance(v, float) and v.is_integer():
return int(v)
if isinstance(v, list):
return [_norm(x) for x in v]
if isinstance(v, dict):
return {k: _norm(x) for k, x in v.items()}
return v
def agreed_values_hash(agreed):
text = json.dumps(_norm(agreed), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(text.encode("utf-8")).hexdigest()
// Node.js
const { createHash } = require("node:crypto");
const sorted = (v) => Array.isArray(v) ? v.map(sorted)
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
const agreedValuesHash = (agreed) => createHash("sha256").update(JSON.stringify(sorted(agreed)), "utf8").digest("hex");
{"status": "linked", "erp_id": "88213", "verified_content_hash": "8ef889b0c3359fbf0146b39d58606235ef0d99d99b7c3c0cbcd3f491db751aff"}
- Every document you have taken carries
verification, worked out each time against the agreed values as they stand now in MerchandiserOS. MerchandiserOS shows the same four answers — they are never merged:value_confirmed— your hash equals the hash of the agreed values now;changed_in_mos— your hash equals what we served when you acknowledged, but an agreed value (a quantity, a price, a line) has changed here since: you hold an older version. A purchase-order change arrives as a new revision; fetch it, store it, and acknowledge it with its new hash;values_differ— your hash matches neither: what you stored is not what we sent;counts_only— you acknowledged without a hash, so only the count is agreed. The first is the only one that makes a month ready;counts_onlyis shown as a note.
- An ack without
verified_content_hash, and a refusal, clear any earlier verdict. verified_content_hashmust be 64 hexadecimal characters, else 422. Hash what you stored, not the response you received — hashing the response proves nothing.- If you change the record inside your ERP after confirming, we cannot see it.
5.1 One feed of what changed — GET /erp/changes
Instead of polling documents and proposals separately, you can read one list of everything that changed for you:
curl -H "Authorization: Bearer $TOKEN" \
"https://<workspace>.merchandiseros.online/api/v1/erp/changes?since=2026-09-25T00:00:00Z&limit=100"
{
"data": [
{"id": "proposal:481:rejected", "type": "proposal_decided", "changed_at": "2026-09-25T09:00:00.000Z",
"link": "/api/v1/erp/proposals/481",
"summary": {"proposal_id": 481, "kind": "payment", "state": "rejected", "subject_ref": "ORD-1042"}},
{"id": "document:purchase_request:12:po_request:1@2026-09-25T10:00:00.000Z", "type": "document",
"changed_at": "2026-09-25T10:00:00.000Z",
"link": "/api/v1/erp/documents/purchase_request%3A12%3Apo_request%3A1",
"summary": {"external_key": "purchase_request:12:po_request:1", "doc_type": "po_request", "status": "pending"}}
],
"next_cursor": null
}
- Three
types:document(a document queued or changed for you),proposal_decided(a person accepted, rejected, or it failed or was superseded), andproposal_partially_approved(the first of two approvals — see section 7). - Each entry links to the full record. The feed itself carries no payload and no amount.
- Sorted by
changed_at, thenid. Page withnext_cursor(opaque) until it isnull.limitis 1–200 (default 50). - Overlap safety. An entry changed exactly at
sinceis included. Poll with thechanged_atof the last entry you processed as the nextsince, and drop any entry whoseidyou have already processed. Anidnames the change itself, so the same record changing again arrives as a newid, never swallowed as a repeat. - Poll every 5–15 minutes. That is what this feed is for, and it stays well inside the call limit (section 10).
6. Send status back
Each of these endpoints takes a list: {"rows": [...]} (or items / mappings / operations where shown), 1 to 500 rows per call. For rows, one bare object is also accepted as a single row. Each row gets its own answer (section 7).
6.1 PO status — POST /erp/po-status
{"rows": [{"request_ref": "PR-1042", "erp_po_id": "88213", "erp_po_number": "PO-2026-0457",
"status": "Pending Receipt", "date": "2026-09-25"}]}
- Use
external_keyinstead ofrequest_refif that is what you hold. One request can become several POs (one per vendor), so send one row per PO. - Status words from NetSuite, Odoo, SAP B1 and Dynamics are understood. We can't read a status word? The row is answered
invalid/unknown_statusand nothing is recorded. - The same PO listed twice with different statuses in one call is answered
conflicting_rows.
6.2 Payments — POST /erp/payments
{"rows": [{"order_ref": "ORD-1042", "milestone": "deposit", "status": "paid", "date": "2026-09-20"}]}
milestoneisdeposit,balanceorlc_received.- Only
status: "paid"is recorded, because the factory keeps its own due dates. - Approving writes the received date on the order.
6.3 Invoices — POST /erp/invoice-status
{"rows": [{"erp_invoice_id": "INV-77120", "doc_number": "CI-2231", "kind": "invoice",
"lifecycle": "issued", "presentation": "discrepancy", "discrepancy_reason": "late B/L",
"payment_state": "unpaid", "issued_date": "2026-09-02", "due_date": "2026-09-30",
"erp_modified_at": "2026-09-24T10:15:00Z", "order_refs": ["ORD-1042"], "shipment_ids": [311]}]}
kindisinvoice,credit_note,down_paymentorproforma.lifecycleisissued,cancelledorreversed.- Credit note: add
reverses_erp_invoice_id(the permanent id of the invoice it corrects). - Documents at the bank:
presentationisnot_presented,presented,acceptedordiscrepancy. A discrepancy needs adiscrepancy_reason. payment_state(unpaid/partial/paid) is your word, stored as given, with no arithmetic.shipment_idsmust belong to the orders you name.
6.3a Supplier invoices — POST /erp/supplier-invoice-status
Your SUPPLIER's invoices against our purchase orders, as a status record — the buying-side sibling of 6.3. Dates and states only; an amount, total or balance is ignored.
{"rows": [{"erp_invoice_id": "ACC-PINV-2026-00012", "doc_number": "INV-55/2026", "po_refs": ["PO-1042"],
"lifecycle": "issued", "kind": "invoice", "payment_state": "partial",
"issued_date": "2026-11-25", "due_date": "2027-01-24", "paid_date": null,
"erp_modified_at": "2026-12-01T09:14:00Z"}]}
- Bound on YOUR permanent
erp_invoice_id;doc_numberis the supplier's number, display only. po_refsare OUR purchase-order numbers the invoice covers.lifecycleisissued,cancelledorreversed;kindisinvoiceordebit_note;payment_stateisunpaid,partialorpaid— your word, stored as given.- An
erp_modified_atolder than the version we hold never replaces it. - A debit note names the invoice it corrects in
reverses_erp_invoice_id(your permanent id). - Optional
po_version(since 2026-10-10): for an invoice naming ONE purchase order, theversionof our purchase-order copy you matched the invoice against. For an invoice over several POs sendpo_versionsinstead —{"PO-1042": 2, "PO-1043": 1}, every key inpo_refs. We keep what you said, as given. Without it — or when that PO has no such version — we record the version in force when your row ARRIVED, shown as "assumed" (and, when you named one we don't have, "Accounts said v3, which doesn't match this PO — recorded against v2"). A singlepo_versionsent for several POs is kept as said, but every link is recorded as assumed. - Same answers and review list as 6.3: nothing is applied on arrival; a person approves it.
- Optional
match_state(since 2026-10-10): your match of the invoice against our PO and receipt —matched,disputedorunmatched— withdispute_reason(up to 300 characters) fordisputedonly. Leave it out and we read it as "you said nothing", never as unmatched. On a known invoice a change is its own item of kindsupplier_invoice_match, accepted automatically (status only — see 6.15) unless the owner switched that kind to manual; on a new invoice it is recorded with the invoice. The PO shows "Supplier invoice INV-55/2026 disputed: price differs from PO", with where it came from and when.
6.4 Claims — POST /erp/claims
{"rows": [{"erp_claim_id": "CLM-301", "kind": "chargeback", "reason_category": "late delivery",
"order_ref": "ORD-1042", "shipment_id": 311, "date": "2026-09-22", "doc_number": "DN-0099"}]}
- Send the reason, never a sum.
- The same
erp_claim_idwith the same details answersmatches. - The same
erp_claim_idwith different details answersconflict, and a person checks it.
6.5 Material prices — POST /erp/material-prices
{"items": [{"material_code": "MAT-201", "price": 3.85, "currency": "USD", "erp_code": "FAB-00931"}]}
- The price is a costing estimate.
- An archived material is refused (
record_archived). - Approving goes through the same price-change path as a person's edit, with the reason and history kept.
6.6 Stock counts — POST /erp/stock-snapshot
{"items": [{"material_code": "MAT-201", "on_hand": 1240.5, "uom": "m", "as_of": "2026-09-25"}]}
- Send a quantity, never money.
- A count within half a percent of ours answers
matches. A bigger difference goes to review with both numbers, and the answer also lists it undervariances. - Our count is never overwritten.
- If
uomdiffers from the unit we count in, the row is not compared (unit_mismatch).
6.7 Item codes — POST /erp/materials/erp-codes
{"mappings": [{"material_code": "MAT-201", "erp_code": "FAB-00931"}]}
To see our confirmed materials first, use GET /erp/materials. It is cursor-paged, supports modified_since, and never shows a price or a provisional material.
6.8 Catalog import — POST /erp/materials/import
{"items": [{"name": "100% Cotton Jersey 180gsm", "erp_code": "FAB-00931", "unit": "kg",
"composition": "100% cotton", "gsm": 180, "width_cm": 180}]}
- Always run it first without
?apply=true. That is a dry run: nothing is recorded, and you get a report (matched,already_mapped,ambiguous,conflict,erp_only,no_erp_code). - Then call again with
?apply=true(and a new Idempotency-Key):- each match becomes a proposal to stamp your code;
- each item we do not have becomes a proposal to create it, with no price.
- Matching is by name, and it only suggests a pair. Nothing is linked until a person approves.
6.9 Production
- Tracked orders.
GET /production/tracked-orderslists the orders whose output comes from an outside system (modified_sincefilters by when tracking was turned on). - Output.
POST /production/outputworks only for those orders:{"rows": [{"order_ref": "ORD-1042", "external_id": "out-2026-09-25-L3-001", "pieces": 420, "date": "2026-09-25", "department": "sewing", "line": "L3"}]}external_idis your unique id for this record. A repeat answersduplicateand is never counted twice.- An order recorded by hand answers
not_tracked.
- Defects (
POST /production/defects) and downtime (POST /production/downtime) are compare only. A person sees them; nothing is written into quality records or planning. - Tracking.
PUT /production/orders/{order_ref}/trackingwith{"mode": "garmentio"}(recorded by an outside system) or"manual"/"job_cards"(recorded by hand):- it is a proposal, because switching locks or unlocks the floor's own entry;
- if the order is already in that state, the answer is
matches.
- Bulletin.
GET /production/orders/{order_ref}/bulletinreturns our operation list and minutes:- an unknown minute is
null, never zero; line_targetsis always empty (per-line targets are not held in the app).
- an unknown minute is
- Actual minutes.
POST /production/orders/{order_ref}/smv-actuals: This is compare only. Costing is never changed by it.{"operations": [{"operation": "Attach sleeve", "actual_smv": 0.92}]}
6.10 Documents and commercial terms
- Export documents (
POST /erp/document-status: commercial invoice, packing list, B/L…) are compare only. The app does not track them yet. - Commercial terms (
POST /erp/commercial-terms) are covered in section 8.
6.11 Your code lists — POST /erp/reference-lists/{payment_terms|incoterms}
{"items": [{"erp_id": "7", "code": "NET30", "name": "Net 30 days"}]}
- This is your own vocabulary, not a change to our records, so it is stored at once (replaced for each
erp_id), not reviewed. - Read it back with
GETon the same path. - A sales order carries your code only where our value equals one of your codes or names exactly.
6.12 What you hold, per month — POST /erp/agreement-counts (optional)
Tell MerchandiserOS how many records of each document type your ERP holds for a month, and how many of those it holds without a MerchandiserOS id (typed directly into your ERP). Counts only — any amount you add is ignored and stored nowhere.
{"month": "2026-10",
"counts": [{"doc_type": "purchase_order", "erp_records": 12, "erp_records_without_mos_id": 2},
{"doc_type": "goods_receipt", "erp_records": 30, "erp_records_without_mos_id": 0}]}
monthisYYYY-MM;doc_typeis one of the outbound document types; both counts are whole numbers, 0 or more, anderp_records_without_mos_idis at mosterp_records. Each type once per call, at most 50 rows. Anything else is refused with 422 and nothing is stored.- Stored at once. A later report for the same month and type replaces the earlier one.
- It is a write, so it needs an
Idempotency-Key. - The owner and Accounts see it beside our own count on Settings → Integrations → Agreement check: "Your accounts system holds 12, 2 of them typed directly there, not from MerchandiserOS." When the records you hold from MerchandiserOS (12 − 2) differ from the documents we count as taken that month, the month is not marked ready.
6.13 Your books are closed up to a date — POST /erp/period-close
Tell MerchandiserOS the last day your books are closed:
{"closed_through": "2026-10-31", "reason": "October closed after the stock count"}
closed_throughis a real day,YYYY-MM-DD(years 2000–2100), and at most tomorrow (UTC) — only days that have happened can be closed;reasonis optional, at most 500 characters. Anything else is refused with 422 and nothing is recorded.- It is one proposal of kind
period_closeon the review list (202). Accounts or the owner accept it. Auto-accept is off unless the owner switches it on. - Once accepted, MerchandiserOS refuses goods receipts, material issues, material returns and opening stock counts dated on or before that day: "Books are closed through 31 Oct 2026 — use a date in an open month, or ask accounts to reopen."
- We never refuse your close — your books are your own. When we still have documents stuck for that period (not collected, not acknowledged, refused, or values that differ — the agreement check's list), the close is recorded "closed with N stuck", each one named and linked, so both sides can see them.
- A date earlier than the one in force reopens the months after it. The review item says "This REOPENS Sep–Oct 2026", the person accepting it must give a reason, and it is never accepted automatically. Kept in the history with both reasons.
- If the books moved after you sent it (another close was accepted, or the owner changed them), accepting it fails: "Accounts' close was based on …, but the books are now closed through … — ask accounts to send it again." Send it again.
- The same date as the one in force answers
matches(200). - The owner can also close the books in MerchandiserOS (Settings → Integrations → Books closed). That is refused there while anything for the period is still stuck, and is not sent to you.
6.14 Approve or block a supplier — POST /erp/supplier-status
Your accounts system owns the supplier list. While your ERP is connected, MerchandiserOS issues a priced purchase order only to a supplier you approved — the Issue button is greyed with the reason ("Delta Weaving is awaiting finance approval in your accounts system"), and the server refuses it too. A factory with no ERP is never asked. Answer each supplier_request:
{"mos_supplier_id": 142, "erp_vendor_id": "NS-88412", "erp_vendor_code": "V-00042",
"state": "approved", "reason": null, "on_hold": false}
stateispending(still checking),approvedorblocked.erp_vendor_idis your system's permanent internal id (up to 120 characters) — the binding key;erp_vendor_codeis display text.reason(up to 500 characters) is shown on the supplier.on_hold, for a supplier already linked to that vendor id, sets the same hold as 6.15 (accepted automatically, status only;reasonbecomes the hold reason) — while on hold, a priced purchase order can't be issued. The most recent arrival on either endpoint wins, and the supplier's history shows both. Anything else in the body — a bank account, IBAN, credit limit, an amount — is never read and never stored.- The first answer for a supplier that is not linked yet is one proposal of kind
supplier_linkon the review list (202). Sourcing or the owner confirms that your vendor is that supplier. A link is learned by a person — never automatically, and auto-accept can never be switched on for this kind. Once accepted, the supplier is linked to yourerp_vendor_idand shows "Approved by accounts (ERP V-00042)" or "Blocked by accounts: …". - Later answers with the same
erp_vendor_idupdate the status at once (200, outcomeapplied, ormatcheswhen nothing changed) — status is your information, not a value MerchandiserOS owns. Each change is kept on the supplier with its source and time. - A different
erp_vendor_idfor a supplier already linked is 409conflict: nothing changes and it is never re-linked — a vendor deleted and re-created is a new record. The refusal is shown on the supplier so the owner can sort it out. A vendor id already linked to another of our suppliers is 409 too. - An unknown
mos_supplier_idis 404; a missingerp_vendor_id, an unknownstateor a bad field is 422. blockedstops any new purchase order to that supplier. Purchase orders already issued are not refused retroactively; their page says the supplier is now blocked.- Suppliers that already existed (before this arrived, or added while no ERP was connected) are not held: POs to them can still be issued, and their page says "Not yet sent to accounts — PO issuing still allowed". Before go-live the owner sends them to you once, in bulk (Settings → Integrations → Supplier approval). From that moment they wait until you approve them. Nothing is approved automatically. After that, every supplier added while your ERP is connected waits from the start.
6.15 Money status — POST /erp/supplier-money-status and POST /erp/po-money-status
Tell MerchandiserOS what your books say, without any amount: a supplier on payment hold or with overdue invoices, a purchase order's advance and its LC, and (on 6.3a) whether a supplier invoice matched. Flags, states and dates only. Any money-shaped field anywhere in the body — amount, balance, outstanding, total, credit_limit, currency, an LC value — is refused with 422, naming the field. MerchandiserOS never holds an amount.
Accepted automatically (founder decision D4). These are status, not values MerchandiserOS owns, so each arrival is applied at once (200, outcome auto_accepted) — and still recorded on the review list under "Accepted automatically", with your integration login and the time it arrived. The owner can switch any of these kinds (supplier_money_status, po_money_status, supplier_invoice_match) to manual in the auto-accept settings; from then on they wait for a person (202). Each changed field is one item (a supplier's hold and its overdue flag are two; a PO's advance and its LC are two). Nothing changed → matches.
Order. Send erp_modified_at (your own change time) on every message — also on 6.14's on_hold. Per field, a message older than the one held, or one without erp_modified_at after a dated one, is never applied: it answers stale and is kept on the review list as "Ignored — older or undated message from your accounts system". An item a person approves later is checked again against what is held at that moment.
Never put an amount in text. Free text is capped — hold_reason and dispute_reason 300 characters, lc.number 60 — and is shown to people as you wrote it. Write "bank details being re-verified", never "USD 12,400 overdue".
A supplier:
{"erp_vendor_id": "NS-88412", "on_hold": true, "hold_reason": "Bank details being re-verified",
"overdue": true, "oldest_overdue_due_date": "2026-09-12", "erp_modified_at": "2026-10-10T08:30:00Z"}
- Name the supplier by OUR
mos_supplier_id, or by your permanenterp_vendor_idonce a person has linked it (6.14). A name is never read. A vendor id no supplier is linked to is 404; one that disagrees with the supplier's link is 409. on_holdandoverdueare required (true / false).hold_reason(up to 300 characters) only withon_hold: true;oldest_overdue_due_dateonly withoverdue: true.- While your ERP is connected, a supplier on hold gets no priced purchase order — whether or not you have approved it yet: the Issue button is greyed with "Delta Weaving is on hold in your accounts system: Bank details being re-verified" and the server refuses it too. Purchase orders already issued stay as they are. With no ERP connected nothing is gated.
- The supplier page shows "On hold in your accounts system: …" and "Overdue invoices since 12 Sep 2026", each "from your accounts system, 10 Oct 2026".
A purchase order:
{"po_number": "PO-1042", "erp_po_id": "ACC-PO-77812",
"advance": {"state": "paid", "paid_date": "2026-10-04"},
"lc": {"number": "12345", "opened_date": "2026-10-01", "expiry_date": "2026-11-30", "state": "opened"}}
- OUR
po_number. A draft PO (never issued) answersnever_sent; an unknown number is 404; anerp_po_iddifferent from the one already held for that PO is 409. - Send
advance,lcor both.advance.stateisnone,requestedorpaid(paid_dateonly withpaid).lc.stateisnone,opened,amended,expiredorclosed;expiry_datemay not be beforeopened_date. Anlckey you leave out keeps what we hold — you can send just{"lc": {"state": "closed"}}. - The PO page shows "Advance paid 4 Oct 2026" and "LC 12345 opened 1 Oct 2026 — expires 30 Nov 2026" — amber within 14 days of expiry, red once expired. An LC within 14 days of expiry, or expired (by date, or your
state: "expired"even with no date), on a PO whose goods are not all received becomes a My Work item for the owner and sourcing.
6.15 A purchase order your system issued — POST /erp/purchase-orders
For factories where your ERP issues the purchase orders (po_mode: erp_issued): send each one you issue. MerchandiserOS holds a read-only copy with no prices, and still records every goods receipt against it — dye lots, incoming QC, returns — and sends each receipt back to you (section 5) carrying your ids.
{"erp_po_id": "NS-PO-88123", "erp_po_number": "4500123",
"supplier": {"erp_vendor_id": "NS-88412"},
"status": "open", "required_by": "2027-02-10", "mos_request_ref": "PR-1042",
"lines": [
{"erp_line_id": "1", "material": {"mos_material_id": 201}, "qty": 600, "unit": "kg", "required_by": "2027-02-10"},
{"erp_line_id": "2", "material": {"item_code": "RIB-2X1", "description": "Rib 2x1"}, "qty": 300, "unit": "m"}
]}
- Only while the factory's PO mode is
erp_issued. Otherwise the call is refused with 409po_mode_operational— that factory issues its purchase orders in MerchandiserOS. - No prices, ever. A field whose name contains the word
price,amount,total,cost,value,rate,tax,vat,discount,balance,chargeorfee(whole words —unit_price,lineTotal,tax_rate; notfeed_idortaxonomy) anywhere in the body is refused with 422amount_not_accepted, naming the field — not silently dropped, so your team knows it is not wanted. MerchandiserOS never reads an amount from an ERP; screens there say "Price held in your accounts system". erp_modified_at(optional, ISO date-time): when the purchase order last changed in your system. Send it. A message older than the one already applied — or one with noerp_modified_atafter a dated one — is recorded asignored(outcomestale) and never applied; an approval that arrives late is re-checked against the newest one applied.- Bind on your permanent ids:
erp_po_id(the purchase order),erp_line_id(each line, unique on the PO),supplier.erp_vendor_id(the vendor you linked to our supplier through 6.14).erp_po_numberanditem_codeare display text. material: ourmos_material_idwhen you know it (it is on thepo_requestwe sent), else youritem_code+description. An item code only finds a material carrying it as its ERP item code (6.7); a person confirms the match.status:open,partially_received,received,closed,cancelledordeleted.required_byon the purchase order or on at least one line (the earliest line date is used); with neither, 422.mos_request_ref: our request number, when the PO answers one.- The first time an
erp_po_idarrives it becomes one proposal of kinderp_po_linkon the review list (202), decided by sourcing or the owner — never automatically (a link is learned by a person; auto-accept can never be switched on for this kind). It cannot be accepted until the supplier is linked to yourerp_vendor_idand every line names a material here: the review list says which, with a link to fix it. Accepting creates the read-only copy (our own PO number, e.g.PO-1088). Each line's material is matched once, when the PO arrives, and that match is what is applied; if a line had no material then, add it in MerchandiserOS and send the purchase order again (the new message replaces the old). A first arrival that is alreadycancelledcreates nothing (no_such_record). - Later, send the purchase order again whenever it changes. Each changed field becomes its own proposal of kind
erp_po_change— the number,required_by, the status, a line's quantity, a line added, a line removed — never a whole-record replace, and the review list shows our value beside yours. Nothing changed →matches. Cancelled or deleted is a change too: a proposal, never a silent stop. A quantity below what was already received, or removing a line goods arrived on, is refused when a person accepts it, with the reason. The owner may switcherp_po_changeto auto-accept (off by default; still recorded) — never for a new line matched only by your item code, which a person confirms. When receipts in MerchandiserOS cover every line, the copy closes on its own; youropenafter that is not a change and raises nothing. A status that genuinely differs names the receipts recorded. A copy cancelled here never changes again — any later change waits, greyed, for a person to reject. - A different
erp_vendor_idon a purchase order already linked isconflict— the supplier on an issued PO never changes: cancel the rest and issue a new one. A line whoseunitchanged isinvalid/unit_mismatch. - If you earlier recorded the PO number against our request by pasting a file (Settings → Integrations), that link is held on the number only and marked fragile. Accepting this purchase order (with
mos_request_refand the same number) teaches it yourerp_po_id. - In MerchandiserOS nobody edits, re-prices, versions, issues or cancels the copy — those controls are read-only there, with the reason. Receiving works as on any purchase order.
7. What goes to review, and how to read the answer
Every write in section 6 (except code lists and acknowledgements) answers like this:
{
"received": 2,
"pending_review": 1,
"state": "pending_review",
"results": [
{"ref": "PR-1042 / PO-2026-0457", "outcome": "queued_for_review", "proposal_id": 481, "state": "pending_review"},
{"ref": "PR-1043 / PO-2026-0458", "outcome": "matches"}
]
}
- 202 with
state: "pending_review": at least one row is waiting for a person. - 200 with
state: "complete": no row is waiting. Every row was answered at once. - A problem with one row never fails the call. That row answers
invalidwith acode. - 422 means the body as a whole is unusable, for example:
- there is no
rowslist; - the list is empty;
- there are more than 500 rows.
- there is no
Reading the decisions. Keep the proposal_ids and poll:
curl -H "Authorization: Bearer $TOKEN" \
"https://<workspace>.merchandiseros.online/api/v1/erp/proposals?modified_since=2026-09-25T00:00:00Z"
- The list is oldest first and cursor-paged.
modified_sincereturns proposals received or decided since then.GET /erp/proposals/{id}returns one proposal, with its approvals (below).- Or read every decision and document in one list:
GET /erp/changes(section 5.1).
Who approves. The factory decides who approves each kind — for example sourcing approves PO status, accounts approves payments and invoices, the merchandiser settles commercial terms, the owner approves anything. You do not need to know who; you only read the answer.
Two approvals. A disagreement on a term that can stop a shipment (LC latest ship date, LC expiry, LC partial shipment, partial shipment, tolerance) — and a stock difference larger than the factory's threshold — needs two different people. After the first approval the proposal still reads pending_review; nothing is applied until the second. Either person may reject it. GET /erp/proposals/{id} shows the progress:
{
"proposal_id": 512, "kind": "commercial_terms", "state": "pending_review",
"approvals_required": 2, "awaiting_second_approval": true,
"approvals": [
{"step": 1, "decision": "approve", "decided_by": "Sara Ahmed", "role": "merchandiser",
"acting_for": null, "decided_at": "2026-09-25T09:10:00.000Z"}
]
}
acting_for names the owner when someone approved on the owner's behalf while the owner was away.
A proposal's state:
| state | meaning |
|---|---|
pending_review | Waiting for a person. |
accepted | Approved by a person, or accepted automatically (decided_automatically: true). result says what changed. |
rejected | A person said no. reason carries what they wrote. |
failed | Approved, but it could not be applied (e.g. the record changed meanwhile). result says why. Send it again if still true. |
superseded | You sent a newer value for the same record and field; the newer one is the one reviewed. |
ignored | A money status (6.15) older than the one held — or undated after a dated one. Kept on the review list, never applied. |
Each proposal shows app_value (what the factory held) beside erp_value (what you said).
Auto-accept. The owner can switch auto-accept on for one kind at a time (for example, PO status). It is off by default. An auto-accepted row answers auto_accepted (or apply_failed) and is still recorded and visible. Commercial terms can never be auto-accepted, and nor can anything that needs two approvals.
What approving each kind does:
| kind | approving it |
|---|---|
po_status | links your PO to our request; a cancelled PO is shown, nothing cancelled |
payment | writes the received date on the order |
invoice | records or updates the invoice status record; no order field changes |
buyer_claim | records the claim against the order |
commercial_terms | settles a disagreement (order unchanged), or fills an empty field on a Draft order |
material_price | changes the unit price, with reason and history |
material_erp_code | stamps your item code on our material |
material_create | creates the material, with no price |
production_output | records the output (deduplicated; refused on a closed order or past the order's limit) |
tracking_mode | switches who records the order's output |
supplier_invoice | records or updates the supplier-invoice status record on our purchase order(s); no amount |
period_close | sets "books closed through" that date; receipts, issues and returns dated on or before it are refused from then on |
supplier_link | links our supplier to your permanent vendor id and sets its status (approved / blocked / still waiting); never auto-accepted. Later answers for that vendor id skip the list |
erp_po_link | creates the read-only copy of a purchase order your system issued, bound on your erp_po_id, with no prices; never auto-accepted, and not acceptable until its supplier is linked and every line names a material |
erp_po_change | changes one field of that copy (number, required-by, status — cancelled / deleted included —, a line's quantity, a line added or removed); off auto-accept unless the owner switches it on |
document_status, stock_variance, production_defect, production_downtime, smv_actual | recorded as seen; nothing in the app changes |
8. Commercial terms and invoices
Commercial terms: the buyer's PO / LC is the source of truth. Your ERP is a witness.
POST /erp/commercial-terms takes one order per call (not a rows list):
{"order_ref": "ORD-1042", "incoterm": "FOB", "payment_terms": "30% deposit, 70% against B/L copy",
"partial_shipment_allowed": false,
"lc": {"latest_ship": "2026-11-15", "expiry": "2026-12-05", "partial_allowed": false},
"tolerance": {"plus_pct": 3, "minus_pct": 3},
"payment_schedule": [{"milestone": "deposit", "due_date": "2026-10-01"}]}
Each term is compared with the order and answered separately:
- Same value:
matches. - Pre-fill: the order is still unconfirmed (Draft) and our field is empty. The term becomes a proposal to fill it, marked "from ERP", and a person still approves it. This covers:
- Incoterm and payment terms;
- partial shipment and LC transhipment;
- LC expiry;
- the deposit and balance due dates.
- Anything else is a disagreement. It is shown with both values. It never changes the order. A person checks the buyer's latest document and records why they decided. Approving settles it; the order still has to be amended from the buyer's PO, by a person, on the order.
While a disagreement is open:
- one on the LC latest ship date, LC expiry, LC partial shipment, partial shipment or tolerance blocks shipping, with no override, until a person settles it;
- one on Incoterm or payment terms warns at ship clearance.
The LC latest ship date is compared with the order's ship date. No amount is read.
Invoices are status only.
- An invoice is bound on
erp_invoice_id.doc_numberis just a label. - The first message about a new
erp_invoice_idis one proposal to record it. - Later messages become one proposal per changed field, plus one if its order or shipment links changed. Each is reviewed on its own.
- A message whose
erp_modified_atis older than the version we hold answersstale. Always senderp_modified_at. - A factory can switch invoice tracking off. Then every invoice row answers
not_tracked.
9. Outcomes and error codes
Row outcomes (results[].outcome):
| outcome | meaning |
|---|---|
queued_for_review | A proposal is waiting for a person (proposal_id). |
already_waiting | The very same proposal was already waiting; same proposal_id. |
auto_accepted | Auto-accept is on for this kind; it was applied. |
apply_failed | Auto-accept tried and could not apply it; reason says why. |
matches | The factory already holds this value. |
no_such_record | Our reference (order, request, material, shipment) was not found. |
never_sent | The purchase request exists but was never sent to the ERP. |
not_tracked | Invoice tracking is off, or the order is recorded by hand. |
duplicate | Repeated in this call, or already recorded. |
stale | Older than the version we hold. |
conflict | We hold your id or number against different details; a person checks it. |
invalid | The row could not be used; see code. |
applied | Applied at once, no review: information only (a linked supplier's status from your accounts system). Recorded with its source and time. |
Row error codes (results[].code, only with invalid). These never change meaning:
| code | meaning |
|---|---|
missing_field | A required field is absent or empty. |
invalid_value | A field has a value we don't accept (wrong enum, bad date, out of range). |
unknown_status | A PO status word we can't read; nothing recorded. |
record_archived | The material is archived. |
unit_mismatch | The unit differs from the one we count in. |
conflicting_rows | The same PO appears twice with different statuses. |
Request errors ({"error": {"code": ..., "message": ...}}):
| HTTP | code |
|---|---|
| 400 | idempotency_key_required, invalid_json, validation_error (a bad query parameter) |
| 401 | unauthorized (sign in again) |
| 404 | not_found |
| 409 | idempotency_key_reused, conflict (acknowledgements; a supplier answer with a different vendor id) |
| 413 | payload_too_large |
| 422 | validation_error (the body as a whole) |
| 429 | rate_limited (see Retry-After) |
| 500 | server_error: nothing was half-written; retry with the same Idempotency-Key |
| 501 | not_available (webhook registration — not built yet; poll instead) |
Code against code. The message and reason texts are written for people and may change.
10. Limits
| Limit | Value |
|---|---|
| Rows per call | 500 |
| Request body | 1,000,000 bytes (413 above it) |
| Calls per integration login | 120 per minute |
| Calls per IP address | 600 per minute |
| Sign-in attempts | 20 per IP and 10 per username, per 10 minutes |
| Wrong passwords before lockout | 5, then 15 minutes |
| Token lifetime | 1 hour |
Page size (limit) | 1–200, default 50 |
| Idempotency keys kept | 24 hours |
11. Where people see what you sent
Once a person approves them, invoices and claims show on the order's Shipping tab ("From your ERP"): one status line per delivery — or one for the order, if the factory has set "we invoice per order" — e.g. Deposit ✓ 14 Aug · Invoice CI-2231 issued · Docs presented 2 Sep · Discrepancy: late B/L · Not paid, due 30 Sep. A buyer's recorded claims are shown on that buyer's draft orders, before they are confirmed. An open disagreement about the order's terms is shown there too, with a link to the review list.
12. What is not built yet
- Webhooks. MerchandiserOS does not push events to your system yet, so
POST /webhooksanswers 501not_available. Poll instead, every 5–15 minutes:GET /erp/changes?since=…gives you new documents and decisions in one list (section 5.1). Most ERP integrations run on a schedule like this anyway. - Payment-term and Incoterm codes. Your codes are only matched exactly, on code or name. There is no screen yet to map a differently-worded term (e.g. our "30 days after B/L" to your
NET30). An unmatched term is sent with its_erpfieldnull. - Compare-only feeds. Defects, downtime, actual minutes, export-document status and stock counts are shown to a person and recorded as seen. They do not change quality records, planning, costing, documents or stock.
- Proposals are per workspace.
GET /erp/proposalslists every ERP proposal in the workspace, not only those sent by your login.