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-only mode, no If-None-Match and 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.

OrderFieldIndexNotes
1InvoiceUniquekey 0Only attempted when > 0
2InvoiceNumberkey 1Only attempted when non-empty
3ReferenceNokey 13Only 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)

OrderFieldIndexNotes
1Uniquekey 0Must 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)

OrderFieldIndexNotes
1VendorIdkey 0Must resolve to an account whose type is P (supplier/payee)
2Namekey 11Exact 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 fieldsIndex used
ItemNumber onlykey -1 (no lookup)
ItemNumber + SubCategorykey 1
ItemNumber + SubCategory + supplierkey 4
ItemNumber + supplierkey 8
PartNumberkey 2
PartNumber + SubCategorykey 12
SupplierPartNumberkey 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.

OrderField(s)Index
1VirtualPartUniquekey 0
2SupplierUnique + SupplierPartNumberkey 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)

OrderFieldIndex
1Uniquekey 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)

OrderFieldIndex
1UnitIdkey 0
2Serialkey 2
3UnitNokey 7

Serial-number matching means re-posting a unit with a known serial updates it.

Keyword (Keyword/addKeyword)

OrderFieldIndex
1Uniquekey 0
2Sortkey 1
3Wordkey 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 ActionResult entry. PutChildRecords discards the per-record outcome. A child record can fail silently while the parent reports success. Verify children with a read-back.
ChildMatch cascade
ContactContactId (key 0, must belong to the parent account), then FirstName+LastName+parent (key 5)
Alternate supplyAltSupplyId (key 0, must belong to the part), then part + supplier + record type
BarcodeAltSupplyId (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"
STASTAId (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.

DomainSafe idempotency fieldNotes
InvoiceReferenceNo (or InvoiceNumber)Best option: put your order id in ReferenceNo on every post
InventoryPartNumber + SubCategoryAlso the minimum required to insert
Virtual inventorySupplierUnique + SupplierPartNumberRequired anyway
VendorNameWeak — names collide
UnitSerialStrong if serials are genuinely unique
KeywordWord or Sort
CustomerUnique onlyNo natural key. Re-posting a new customer always inserts. Track the returned RecordId yourself.
AP billUnique onlyNo 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 IsSuccess is 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:

  1. Always read the per-record array; never act on the top-level flag alone.
  2. Never blind-retry a batch. Retry only the records whose ActionSuccess was false, and only if they carry a match field (section 3) so the retry cannot duplicate.
  3. 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.
  4. 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.