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.

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:

  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.

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: 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_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 and codes, never a price. A sales order also carries 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.

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) or linked (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_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.

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.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.

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.

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.

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:

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
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.

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)
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/documents?modified_since=… for new documents and GET /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 _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.