Home/ERP API

For ERP implementation teams

Connect your ERP to MerchandiserOS

One open API for any ERP — NetSuite, Odoo, SAP Business One, Microsoft Dynamics or your local accounting system. Your ERP keeps the money; MerchandiserOS runs the work; every change your ERP sends is approved by a person.

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:

  1. 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).
  2. 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.

  1. In MerchandiserOS: Settings → Integrations → ERP → "ERP API".
  2. The owner types what the login is for (for example "Odoo — purchasing") and creates it.
  3. 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_at and 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-After header.

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:

RecordOur reference
Orderorder number, e.g. ORD-1042
Purchase requestrequest number, e.g. PR-1042, or the external_key of the document we sent
Materialmaterial code, e.g. MAT-201
Shipmentour 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 recordField
Purchase ordererp_po_id (required)
Invoice / credit noteerp_invoice_id (required)
Claim / chargeback / debit noteerp_claim_id (required)
Code-list entryerp_id (required)
A document you acknowledgeerp_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_modeWho issues the POBuying documents you receive
operational (default)MerchandiserOSpurchase_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_issuedYour ERPpo_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_cursor back as ?cursor= until it is null. limit is 1–200 (default 50).
  • ?modified_since=2026-09-25T00:00:00Z returns only documents changed since then.
  • Fetch one document: GET /erp/documents/{external_key}. The key looks like purchase_request:12:po_request:1, so URL-encode it.
  • The payload carries quantities, units, dates, codes and ids. A purchase request never carries a price. A sales order also carries:
    • buyer_id and style_id: OUR permanent ids for the buyer and the style. Bind your customer and item on these, never on a name. null when the order links none.
    • lines: the colour × size breakdown, [{"colour": "Navy", "size": "M", "qty": 600}], in counting_unit. Empty when no breakdown is recorded yet, with lines_note saying so. If the lines do not add up to qty, both are sent as they stand and lines_note says so. Nothing is corrected for you.
    • price: the buyer's agreed selling price, {"unit_price": 1.85, "currency": "USD", "per": "pair"}, per counting_unit, in the order's own currency (never converted). null when no agreed price is recorded, with price_note saying 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, price is null too: a price per an assumed unit would be wrong by 2× for pairs. An order with no currency chosen is stored in USD, so currency is USD unless 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 revision and updated_at do not move when the price, quantity or breakdown is amended, so modified_since and /erp/changes do 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_terms and payment_terms_erp
    • incoterm and incoterm_erp
    • tolerance, 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_price is yours to work out.
  • version is 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_at is when it took effect (null, with version_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 revision is queued (a new external_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. status is Issued, Acknowledged, Dispatched, Received, Closed short (the supplier should not invoice the shortfall) or Cancelled (with cancelled_reason).
  • A missing fact is null with a note: a line with no library material has material_id: null, its description and material_note; a single-material PO has one line with po_line_id: null and po_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 than received_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 equals received_date.
  • qty is what physically arrived; book accepted_qty into stock and rejected_qty into a rejected warehouse. accepted_qty + rejected_qty = qty. inspection_ref is null when the result was recorded by hand or no inspection was required. A receipt recorded before acceptance was kept reads all accepted, with a qc_note saying 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_note says 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_id and erp_po_number (yours) and erp_line_id (your id for the line). Match the receipt to your PO on erp_po_id + erp_line_id — never on a number or a name. On a purchase order MerchandiserOS issued they read issued_by: "mos" and null. A supplier_return carries 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_code are filled once a person here has confirmed the link to your vendor.
  • contact is 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 in contact, 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 on return_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"}'
  • status is acknowledged (you have it), linked (you created your record from it) or refused (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_id is stored, a different erp_id for 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."}
  • reason is required, at most 500 characters (a longer one is refused with 422, never cut).
  • The document becomes error, with error: "Refused by the ERP: <reason>". It leaves the open list, and the reason shows in MerchandiserOS wherever the document's status shows.
  • A linked document cannot be refused (409).
  • Fixed your side? Send acknowledged or linked — it is accepted and clears the reason.
  • Optionally add a short machine code beside 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 as refusal_code:
{"status": "refused", "reason": "The October period is closed.", "code": "period_closed"}
  • period_closed has 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 whose payload is now null (a closed order) still has it.
  • content_hash — the SHA-256, lower-case hex, of agreed_values written as canonical JSON: object keys sorted at every level, arrays in their order, no spaces, UTF-8, numbers as JSON writes them (1200, not 1200.0; 3.2, not 3.20). We serve agreed_values in exactly that form. Both are null for a type with no shape yet.
doc_typeagreed_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 typenull — 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_only is shown as a note.
  • An ack without verified_content_hash, and a refusal, clear any earlier verdict.
  • verified_content_hash must 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), and proposal_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, then id. Page with next_cursor (opaque) until it is null. limit is 1–200 (default 50).
  • Overlap safety. An entry changed exactly at since is included. Poll with the changed_at of the last entry you processed as the next since, and drop any entry whose id you have already processed. An id names the change itself, so the same record changing again arrives as a new id, 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_key instead of request_ref if 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_status and 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"}]}
  • milestone is deposit, balance or lc_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]}]}
  • kind is invoice, credit_note, down_payment or proforma.
  • lifecycle is issued, cancelled or reversed.
  • Credit note: add reverses_erp_invoice_id (the permanent id of the invoice it corrects).
  • Documents at the bank: presentation is not_presented, presented, accepted or discrepancy. A discrepancy needs a discrepancy_reason.
  • payment_state (unpaid / partial / paid) is your word, stored as given, with no arithmetic.
  • shipment_ids must 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_number is the supplier's number, display only.
  • po_refs are OUR purchase-order numbers the invoice covers.
  • lifecycle is issued, cancelled or reversed; kind is invoice or debit_note; payment_state is unpaid, partial or paid — your word, stored as given.
  • An erp_modified_at older 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, the version of our purchase-order copy you matched the invoice against. For an invoice over several POs send po_versions instead — {"PO-1042": 2, "PO-1043": 1}, every key in po_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 single po_version sent 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, disputed or unmatched — with dispute_reason (up to 300 characters) for disputed only. 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 kind supplier_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_id with the same details answers matches.
  • The same erp_claim_id with different details answers conflict, 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 under variances.
  • Our count is never overwritten.
  • If uom differs 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-orders lists the orders whose output comes from an outside system (modified_since filters by when tracking was turned on).
  • Output. POST /production/output works 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_id is your unique id for this record. A repeat answers duplicate and 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}/tracking with {"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}/bulletin returns our operation list and minutes:
    • an unknown minute is null, never zero;
    • line_targets is always empty (per-line targets are not held in the app).
  • 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 GET on 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}]}
  • month is YYYY-MM; doc_type is one of the outbound document types; both counts are whole numbers, 0 or more, and erp_records_without_mos_id is at most erp_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_through is a real day, YYYY-MM-DD (years 2000–2100), and at most tomorrow (UTC) — only days that have happened can be closed; reason is optional, at most 500 characters. Anything else is refused with 422 and nothing is recorded.
  • It is one proposal of kind period_close on 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}
  • state is pending (still checking), approved or blocked. erp_vendor_id is your system's permanent internal id (up to 120 characters) — the binding key; erp_vendor_code is 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; reason becomes 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_link on 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 your erp_vendor_id and shows "Approved by accounts (ERP V-00042)" or "Blocked by accounts: …".
  • Later answers with the same erp_vendor_id update the status at once (200, outcome applied, or matches when 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_id for a supplier already linked is 409 conflict: 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_id is 404; a missing erp_vendor_id, an unknown state or a bad field is 422.
  • blocked stops 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 permanent erp_vendor_id once 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_hold and overdue are required (true / false). hold_reason (up to 300 characters) only with on_hold: true; oldest_overdue_due_date only with overdue: 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) answers never_sent; an unknown number is 404; an erp_po_id different from the one already held for that PO is 409.
  • Send advance, lc or both. advance.state is none, requested or paid (paid_date only with paid). lc.state is none, opened, amended, expired or closed; expiry_date may not be before opened_date. An lc key 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 409 po_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, charge or fee (whole words — unit_price, lineTotal, tax_rate; not feed_id or taxonomy) anywhere in the body is refused with 422 amount_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 no erp_modified_at after a dated one — is recorded as ignored (outcome stale) 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_number and item_code are display text.
  • material: our mos_material_id when you know it (it is on the po_request we sent), else your item_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, cancelled or deleted. required_by on 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_id arrives it becomes one proposal of kind erp_po_link on 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 your erp_vendor_id and 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 already cancelled creates 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 switch erp_po_change to 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; your open after 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_id on a purchase order already linked is conflict — the supplier on an issued PO never changes: cancel the rest and issue a new one. A line whose unit changed is invalid / 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_ref and the same number) teaches it your erp_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 invalid with a code.
  • 422 means the body as a whole is unusable, for example:
    • there is no rows list;
    • the list is empty;
    • there are more than 500 rows.

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_since returns 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:

statemeaning
pending_reviewWaiting for a person.
acceptedApproved by a person, or accepted automatically (decided_automatically: true). result says what changed.
rejectedA person said no. reason carries what they wrote.
failedApproved, but it could not be applied (e.g. the record changed meanwhile). result says why. Send it again if still true.
supersededYou sent a newer value for the same record and field; the newer one is the one reviewed.
ignoredA 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:

kindapproving it
po_statuslinks your PO to our request; a cancelled PO is shown, nothing cancelled
paymentwrites the received date on the order
invoicerecords or updates the invoice status record; no order field changes
buyer_claimrecords the claim against the order
commercial_termssettles a disagreement (order unchanged), or fills an empty field on a Draft order
material_pricechanges the unit price, with reason and history
material_erp_codestamps your item code on our material
material_createcreates the material, with no price
production_outputrecords the output (deduplicated; refused on a closed order or past the order's limit)
tracking_modeswitches who records the order's output
supplier_invoicerecords or updates the supplier-invoice status record on our purchase order(s); no amount
period_closesets "books closed through" that date; receipts, issues and returns dated on or before it are refused from then on
supplier_linklinks 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_linkcreates 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_changechanges 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_actualrecorded 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_number is just a label.
  • The first message about a new erp_invoice_id is 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_at is older than the version we hold answers stale. Always send erp_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):

outcomemeaning
queued_for_reviewA proposal is waiting for a person (proposal_id).
already_waitingThe very same proposal was already waiting; same proposal_id.
auto_acceptedAuto-accept is on for this kind; it was applied.
apply_failedAuto-accept tried and could not apply it; reason says why.
matchesThe factory already holds this value.
no_such_recordOur reference (order, request, material, shipment) was not found.
never_sentThe purchase request exists but was never sent to the ERP.
not_trackedInvoice tracking is off, or the order is recorded by hand.
duplicateRepeated in this call, or already recorded.
staleOlder than the version we hold.
conflictWe hold your id or number against different details; a person checks it.
invalidThe row could not be used; see code.
appliedApplied 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:

codemeaning
missing_fieldA required field is absent or empty.
invalid_valueA field has a value we don't accept (wrong enum, bad date, out of range).
unknown_statusA PO status word we can't read; nothing recorded.
record_archivedThe material is archived.
unit_mismatchThe unit differs from the one we count in.
conflicting_rowsThe same PO appears twice with different statuses.

Request errors ({"error": {"code": ..., "message": ...}}):

HTTPcode
400idempotency_key_required, invalid_json, validation_error (a bad query parameter)
401unauthorized (sign in again)
404not_found
409idempotency_key_reused, conflict (acknowledgements; a supplier answer with a different vendor id)
413payload_too_large
422validation_error (the body as a whole)
429rate_limited (see Retry-After)
500server_error: nothing was half-written; retry with the same Idempotency-Key
501not_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

LimitValue
Rows per call500
Request body1,000,000 bytes (413 above it)
Calls per integration login120 per minute
Calls per IP address600 per minute
Sign-in attempts20 per IP and 10 per username, per 10 minutes
Wrong passwords before lockout5, then 15 minutes
Token lifetime1 hour
Page size (limit)1–200, default 50
Idempotency keys kept24 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 /webhooks answers 501 not_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 _erp field null.
  • 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/proposals lists every ERP proposal in the workspace, not only those sent by your login.