Source of truth: CM_Base.PutRecords / PutChildRecords, and the FindRecordMatch / AddUpdateRecord implementations in each CM_* unit.
This is the single most important page in this guide, and the one thing Swagger cannot tell you. Read it before writing any code that POSTs.
1. add does not mean insert
Every endpoint named addInvoice, addCustomer, addInventory, addVendor, addCategories, addUnit, addKeyword, addAPBills, addVirtualInventory — and every *_Insert / *_Update pair on the legacy surface that routes through the same pipeline — is an upsert.
For each record you submit, the server runs two steps:
FindRecordMatch(record) -> decides Insert / Update / NoOp, and picks the target record AddUpdateRecord(record) -> performs that operation
FindRecordMatch searches the System Five data for an existing record that "looks like" the one you sent, using a domain-specific cascade of match attempts. If any attempt hits, your add silently becomes an update of that existing record.
The practical risk: send a payload that happens to carry a name, part number, invoice number or reference that already exists, and you will overwrite a live System Five record rather than creating a new one. There is no
insert-onlymode, noIf-None-Matchand no flag to disable matching.
Equally, the reverse: send an update without the field the matcher needs and you will create a duplicate instead of updating.
2. The match cascade, per domain
Each cascade is evaluated in order and stops at the first hit. "Key n" is the Btrieve index used.
Invoice (Invoice/addInvoice)
Match fields are read from the InvoiceHeader object.
| Order | Field | Index | Notes |
|---|---|---|---|
| 1 | InvoiceUnique | key 0 | Only attempted when > 0 |
| 2 | InvoiceNumber | key 1 | Only attempted when non-empty |
| 3 | ReferenceNo | key 13 | Only attempted when non-empty |
No match found -> insert.
The ReferenceNo step is the dangerous one for e-commerce integrations: if you put your own order number in ReferenceNo and re-post the same order, the second post updates the first invoice instead of creating a duplicate. That is usually what you want — but it means ReferenceNo is effectively your idempotency key, so it must be unique per order in your system.
Customer (Customer/addCustomer)
| Order | Field | Index | Notes |
|---|---|---|---|
| 1 | Unique | key 0 | Must resolve to an account whose type is R (retail customer) |
There is no name-based fallback. No match -> insert.
If the Unique you send exists but is not a customer (AType <> 'R'), the record is skipped as a no-op with:
Unique provided exists, but does not correspond to a Customer record. Customer record skipped
Vendor (Vendors/addVendor)
| Order | Field | Index | Notes |
|---|---|---|---|
| 1 | VendorId | key 0 | Must resolve to an account whose type is P (supplier/payee) |
| 2 | Name | key 11 | Exact name match against AType = 'P' |
Step 2 is a name match. Two different vendors that happen to share a Name are indistinguishable to this API — posting the second one updates the first.
If the VendorId exists but is not a vendor:
VendorId provided exists, but does not correspond to a Vendor record. Vendor record skipped
Account (used internally by invoice billing/shipping)
Same shape as Vendor but without the type restriction: AccountId (key 0), then Name (key 11).
Inventory / parts (Inventory/addInventory)
The most elaborate cascade. Default operation is NoOp — inventory will refuse rather than guess.
Step 1. InventoryId (key 0) — direct unique match.
Step 2. Otherwise, gather SubCategory, VendorName (resolved to a supplier id by exact name lookup on AType = 'P', key 11), PartNumber, SupplierPartNumber, ItemNumber, then:
| Available fields | Index used |
|---|---|
ItemNumber only | key -1 (no lookup) |
ItemNumber + SubCategory | key 1 |
ItemNumber + SubCategory + supplier | key 4 |
ItemNumber + supplier | key 8 |
PartNumber | key 2 |
PartNumber + SubCategory | key 12 |
SupplierPartNumber | key 5 |
Where a supplier is supplied but no composite index exists, the code scans forward matching supplier manually — including matching records whose supplier is currently unset (SupplierL = 0), which are then adopted.
Step 3. If none of PartNumber, SupplierPartNumber or ItemNumber was supplied:
No Part or Item or Supplier Part Number provided. Insufficient info to update or insert; Inventory record skipped
Step 4. To insert a new part you must supply both SubCategory and PartNumber. Anything less is rejected:
Category *and* one of the part numbers required to insert new record; Inventory record skipped
Virtual inventory (VirtualInventory/addVirtualInventory)
Default operation is NoOp.
| Order | Field(s) | Index |
|---|---|---|
| 1 | VirtualPartUnique | key 0 |
| 2 | SupplierUnique + SupplierPartNumber | key 3 |
Both of the step-2 fields are required when there is no unique. Missing either gives:
No Supplier Unique provided. Insufficient info to update or insert; Inventory record skipped No Supplier Part Number provided. Insufficient info to update or insert; Inventory record skipped
AP bill (APBill/addAPBills)
| Order | Field | Index |
|---|---|---|
| 1 | Unique | key 0 |
No fallback. Without a Unique every post is an insert — there is no duplicate protection on bill number. Guard against double-submission yourself.
Category (Category/addCategories)
Matched on CategoryNumber (key 0), converted through System Five's ledger-number parser. A blank/unparseable category number is a no-op:
Category number missing, record skipped
Unit (Units/addUnit)
| Order | Field | Index |
|---|---|---|
| 1 | UnitId | key 0 |
| 2 | Serial | key 2 |
| 3 | UnitNo | key 7 |
Serial-number matching means re-posting a unit with a known serial updates it.
Keyword (Keyword/addKeyword)
| Order | Field | Index |
|---|---|---|
| 1 | Unique | key 0 |
| 2 | Sort | key 1 |
| 3 | Word | key 2 |
Child records (contacts, alternate suppliers, barcodes, STAs)
These are posted as arrays nested inside their parent record and processed by PutChildRecords. Two things differ from top-level records:
- They require the parent's id, passed positionally by the parent controller. If it is missing the child is skipped:
Inventory ID not specified. ... record skipped,Customer/VendorID not specified. Contact record skipped. - They produce no
ActionResultentry.PutChildRecordsdiscards the per-record outcome. A child record can fail silently while the parent reports success. Verify children with a read-back.
| Child | Match cascade |
|---|---|
| Contact | ContactId (key 0, must belong to the parent account), then FirstName+LastName+parent (key 5) |
| Alternate supply | AltSupplyId (key 0, must belong to the part), then part + supplier + record type |
| Barcode | AltSupplyId (key 0, must belong to the part), then a scan on barcode value; defaults to insert because "suppliers can have many barcodes for the same part" |
| STA | STAId (key 0, must belong to the part), then part + active + start date (+ STANumber if supplied) |
Note that both alternate supply and barcode accept a SupplierName instead of a SupplierId; the lookup is documented in the model as exact match only.
3. Idempotency: what to send so a retry is safe
There is no idempotency key. You create one by choosing a match field and always sending it.
| Domain | Safe idempotency field | Notes |
|---|---|---|
| Invoice | ReferenceNo (or InvoiceNumber) | Best option: put your order id in ReferenceNo on every post |
| Inventory | PartNumber + SubCategory | Also the minimum required to insert |
| Virtual inventory | SupplierUnique + SupplierPartNumber | Required anyway |
| Vendor | Name | Weak — names collide |
| Unit | Serial | Strong if serials are genuinely unique |
| Keyword | Word or Sort | |
| Customer | Unique only | No natural key. Re-posting a new customer always inserts. Track the returned RecordId yourself. |
| AP bill | Unique only | No natural key. Same caution. |
For Customer and AP bill, capture RecordId from the first successful response and send it back on every subsequent write. If you lose it, you cannot recover idempotency and you will create duplicates.
4. Batching, ordering and failure isolation
for currRecord in aAPIInput.Records do begin FindRecordMatchProc(currRecord, actionParams, aParams); AddUpdateRecordProc(currRecord, actionParams, aParams); ... aResults.Response.IsSuccess := aResults.Response.IsSuccess and oActionResult.Success; end;
- Records are processed sequentially, in array order.
- There is no transaction. Each record is committed as it is processed.
- A failure does not stop the loop; subsequent records are still processed.
- The overall
IsSuccessis the AND of every record — so one failure among fifty makes the whole call report failure while forty-nine records were written.
Consequences for your client:
- Always read the per-record array; never act on the top-level flag alone.
- Never blind-retry a batch. Retry only the records whose
ActionSuccesswasfalse, and only if they carry a match field (section 3) so the retry cannot duplicate. - Keep batches small enough that a partial failure is cheap to reconcile. The service holds a global lock for the whole batch, so large batches also block every other caller.
- Order matters where records depend on each other (a part before the invoice line that references it). The API will not reorder for you.
5. NoOp — the third outcome
TPostRecordMode has three values: prmInsert, prmUpdate and prmNoOp. A record set to NoOp is skipped entirely — nothing is written, and the ActionResult carries the "record skipped" message. Inventory and virtual inventory default to NoOp, so an under-specified record there does nothing at all rather than inserting something wrong.
ActionSuccess for a skipped record is false in most domains, and RecordId is whatever identifier you supplied (often "0"). Stocked inventory is the exception — its no-op branch reports ActionSuccess: true, so on Inventory/addInventory check RecordId and ActionResult rather than the flag (see Inventory). A skipped record is a client error — fix the payload rather than retrying.
6. Errors that do not surface as failures
Domain AddUpdateRecord implementations wrap their sub-steps in try ... except blocks that write a message into OpResult but leave OpSuccess untouched. For invoices, for example, there are separate handlers for header, billing, shipping, line and tender processing, each of which can record:
An error occurred while adding Line information An error occurred while adding Tender information An error occurred while adding Header information
while the surrounding insert still reports ActionSuccess: true and "Inserted Record, subject to System 5 verification".
Rule: treat a record as clean only when ActionSuccess is true and ActionResult does not contain "An error occurred". Anything else needs a read-back before you consider the document complete.
7. Recommended write pattern
1. Build the payload with a stable match field (section 3).
2. POST with ?DetailedResponse=Y so the written record comes back.
3. Unwrap "result" if present.
4. For each entry in the domain array:
- record RecordId (this is your handle for future updates)
- if ActionSuccess is false -> fix and retry that record only
- if ActionResult has "An error" -> read the record back and reconcile
- otherwise -> done
5. Never resubmit the whole batch.


