MerchandiserOS ERP API — guide for your implementation team
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.
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: purchase requests (po_request) and sales orders (sales_order). 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 and codes, never a price. A sales order also carriesterms: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.
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) orlinked(you created your record from it).- 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.
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.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.
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.
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. |
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.
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 |
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. |
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) |
| 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/documents?modified_since=…for new documents andGET /erp/proposals?modified_since=…for decisions. 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.