URL class: Customer — /Windward/WebAPI/Customer/...
Source of truth: ServerMethodsCustomer.pas, CM_Customer.pas, CM_Account.pas, CM_Contact.pas, AO_Customer.pas.
The older customer operations on TServerMethodsWebAPI (Get_Customers, List_Customers, Get_Customer_By_Email, Insert_Retail_Customer, Update_Retail_Customer, Customers_Insert/Read/Update) are covered in The Legacy TServerMethodsWebAPI Surface. Prefer this class for new work.
Operations
| Verb | Path | Delphi method | Envelope |
|---|---|---|---|
| GET | /Customer/Customer_Handshake | Customer_Handshake | ad hoc |
| GET | /Customer/GetCustomerRecordCount | GetCustomerRecordCount | ad hoc |
| GET | /Customer/Customers/{Unique} | Customers | APIResponse + Customer |
| GET | /Customer/CustomerChanges | CustomerChanges | APIResponse + Customer |
| POST | /Customer/addCustomer | updateaddCustomer | APIResponse + Customer |
What "customer" means here
A System Five account record carries a type. This class only deals with accounts of type R (retail customer). The same underlying file holds vendors (type P) and other account kinds, which is why several operations check the type and refuse to act on the wrong one.
Contacts are child records of a customer and are managed through the Contacts array inside the customer payload — there is no standalone contact endpoint.
POST /Customer/addCustomer
An upsert. See The Upsert Model for the shared rules.
Request shape
{
"ConnectionInfo": { "TerminalNumber": 1 },
"Customers": [
{
"Unique": 0,
"Name": "Bloggs, Jo",
"FirstName": "Jo",
"LastName": "Bloggs",
"Addr1": "1 High St",
"City": "Vernon",
"State": "BC",
"Country": "Canada",
"Postal": "V1T 1A1",
"Email": "jo@example.com",
"Department": 0,
"Contacts": [ { "FirstName": "Sam", "LastName": "Smith" } ]
}
]
}Match rule — and why this endpoint duplicates easily
The only match attempt is Unique (key 0), and the matched account must have type R.
- No
Unique, orUniqueof0-> always an insert. There is no name, email or phone based matching. - A
Uniquethat exists but is not a customer is skipped:Unique provided exists, but does not correspond to a Customer record. Customer record skipped
There is no natural key for customers. Posting the same person twice creates two accounts. Capture
RecordIdfrom the response on first creation and store it on your side; send it back asUniqueon every subsequent write. If you need to find an existing customer before deciding, usePOST /TServerMethodsWebAPI/Get_Customer_By_EmailorGet_Customerswith filters (see The Legacy TServerMethodsWebAPI Surface).
The name gate
Before anything is written, the server derives a business name:
- If
Nameis present and non-empty, use it. - Otherwise, if both
FirstNameandLastNameare present and non-empty, build the name from them. - Otherwise the record is rejected with:
An error occurred while adding the Name or First and Last Names passed. Check your parameters.
The composition in step 2 depends on a System Five setup question, SQ_BusinessNameFormatToFirstLast:
| Setup question | Resulting Name |
|---|---|
| enabled | FirstName + " " + LastName -> Jo Bloggs |
| disabled (default) | LastName + ", " + FirstName -> Bloggs, Jo |
The same payload therefore produces a different stored name on different datasets. If the exact stored name matters to you, send Name explicitly rather than relying on the derivation.
Insert path: defaults come from a template account
For an insert, the customer is created with CreateNew('R', dept, iDefault, sAName), where iDefault is the id of a default customer record chosen by GetDefaultCustomerRecord(Country, State, Department) using the Country, State and Department from your payload.
This is important and entirely undocumented in Swagger: the new customer inherits terms, tax settings, price schedule and other defaults from a template account selected by country/state/department. Fields you do not send are not blank — they carry the template's values. Sending Country and State changes which template is used, and therefore changes tax and pricing defaults.
After creation, every field you did send is applied over the top.
Field application rules
Each field is applied only when present in the payload; absent fields are left alone (on update, existing values survive). Type coercions to know about:
| Kind | Fields | Rule |
|---|---|---|
| Single character | Duty, FedTax, AutoDiscount, CSType, TaxStatus, GSTExempt, RetailType, Foreign | First character only. Sending an empty string raises an exception, caught as An error occured during the update or insert of the record, and fails the record. Omit rather than send "". |
| Date | ContractDate, POExpiryDate, LastVisit | ISO 8601. An unparseable value silently becomes the Pervasive zero date. |
| Department/currency scoped | POBilledSoFar, Balances | Written against the account's current Department and CurrencyCode. If you are also changing Department or CurrencyCode in the same payload, the order of application matters — send them in a prior call if you need certainty. |
| Boolean | ECommerce | JSON boolean |
| Ignored | WarningComments, LookupWords | Present in the model but not written — there is no corresponding account property. |
Shipping address — a conditional you will trip over
The block that saves ShipAddr1 / ShipCity / ShipState / ShipCountry / ShipPostal / ShipPhone1 only runs when ShipName is present and equal to the empty string:
if TryGetJSONValue(aRecord, aocustShipName, sValue, false) and (sValue = '') then
So:
- Omitting
ShipNameentirely -> the shipping address is not saved. - Sending
"ShipName": ""-> the shipping address is saved. - Sending a real
ShipName-> the shipping address is not saved.
To store a shipping address through this endpoint you must send "ShipName": "" alongside the address fields. The address is stored as a free-text block (SaveShippingAddress), one line per supplied field in the order addr1, addr2, city, state, country, postal, phone.
Contacts
Contacts is an array of contact objects, processed only when the customer write succeeded and produced a RecordId > 0. Each contact is matched by:
ContactId(key 0) — must already belong to this customer, otherwise skipped withContactId provided exists, but does not belong to the Customer/Vendor record. Contact record skippedFirstName+LastName+ customer (key 5), compared case-insensitively
Contacts produce no result entries. They go through
PutChildRecords, which discards per-record outcomes. A contact can fail silently while the customer reports success. Read the customer back if contacts matter.
Response
{
"APIResponse": { "IsSuccess": true, "Response": "", "RecordCount": "1" },
"Customer": [
{ "RecordId": "8842", "RecordDesc": "Bloggs, Jo",
"ActionResult": "Inserted Record, subject to System 5 verification",
"ActionSuccess": true, "RecordResult": null }
]
}RecordDesc is the stored business name — useful for confirming which naming convention the dataset applied.
Query parameters
| Parameter | Effect |
|---|---|
DetailedResponse=Y | Populate RecordResult with the written customer |
Fields=... | Shape RecordResult; implies DetailedResponse=Y |
GET /Customer/Customers/{Unique}
{Unique} | Behaviour |
|---|---|
> 0 | Return that one customer |
0 | Return all customers — paginate or expect a very large response |
Query parameters
| Parameter | Notes |
|---|---|
Fields | Comma-separated field names, matched against the response model |
PageSize | Only meaningful with {Unique} = 0 |
PageNumber | 1-based; required whenever PageSize > 0 |
FreeFormNameMap=Y | Adds the FreeFormHeaders map (see Common Parameters) |
Pagination is a forward scan — cost grows with page number
GetNextID finds the first record of the requested page by walking the customer index from the beginning, counting records until it passes PageSize * (PageNumber - 1).
- Page 1 is free; page 100 with
PageSize=500walks ~49 500 records first. - The walk happens inside the service's global lock, so deep pages stall the whole API.
- Requesting a page beyond the end sets no records and adds an informational message to
APIResponse.Response:
No records found on page 12
Practical guidance: use modest page counts, or prefer CustomerChanges with an advancing date watermark for ongoing synchronisation.
The unpaginated ceiling
With no pagination the walk stops reporting at cMaxPageSize (50 000) and appends to APIResponse.Response:
Max Page Size of 50000 exceeded!
Note this is a message, not an error — IsSuccess can still be true. If you see this text your result set is truncated.
Response
Array key Customer. Each record carries the account fields listed in the field reference below, plus:
Contacts— an array of the customer's contact recordsFreeFormGroup— free-form field values, by numeric idShippingAddress— the stored shipping address block
GET /Customer/CustomerChanges
Returns customers changed on or after a date, using System Five's record state tracking.
| Query parameter | Required | Notes |
|---|---|---|
EffectiveDateTime | yes | ISO 8601, server local time |
PageSize / PageNumber | no | Both or neither |
Prerequisite: record state tracking must be enabled
If it is not, the endpoint returns the legacy envelope immediately and does no work:
{ "Response": "Failed",
"Reason": "Record State tracking has not been enabled for this database; FetchChanges cannot work without that setting turned on" }This is a System Five configuration setting, not an API parameter. Every *Changes / FetchChanges endpoint in this API has the same prerequisite. Check it once during onboarding rather than per call.
Other early returns (also legacy envelope)
{ "Response": "Failed", "Reason": "Invalid EffectiveDate found (...); must be in ISO8601 format" }
{ "Response": "Success", "Reason": "No record changes in Customers were found for EffectiveDate: (...)" }Only when there is at least one change does the response switch to the APIResponse + Customer shape. This endpoint can return either envelope generation — handle both.
What "changed" means
The record-state file records touched records with a timestamp. The endpoint fetches the record ids changed at or after your date, then reads each customer in full. Consequences:
- You get whole records, not diffs. There is no indication of which fields changed.
- A record touched but not materially altered still appears.
- Deletions are not distinguishable from updates through this endpoint — you receive the current state of whatever ids are listed.
- Pagination here pages the change list, not the customer file, so it does not suffer the forward-scan cost of
Customers. Prefer this endpoint for sync.
Recommended sync loop
watermark = last successful run time (server local)
loop:
GET /Customer/CustomerChanges?EffectiveDateTime={watermark}&PageSize=500&PageNumber={n}
until fewer than PageSize records come back
watermark = time the run started (not finished)Use the run start time as the next watermark so records changed during the run are picked up next time. Expect to reprocess a small overlap; the payloads are full records so reprocessing is idempotent on your side.
GET /Customer/GetCustomerRecordCount
{ "SystemFive API Record Count": "Customers", "Record Count": "12043" }Ad hoc envelope; both keys contain spaces and Record Count is a string.
Note the count is produced by walking every customer record on the name index, not by a stored counter. It is an O(n) call under the global lock. Do not poll it.
GET /Customer/Customer_Handshake
{ "System Five Customer API": "Handshake", "Version": "1.2.3.4" }Field reference
Input fields for addCustomer; the same names appear in GET responses.
Identity and name
| Field | Type | Notes |
|---|---|---|
Unique | integer | Match key. 0/absent -> insert |
Name | string | Business name. Preferred — avoids the derivation rule |
FirstName, LastName | string | Used to derive Name when Name is blank; both required |
Number | string | Customer number |
Address and contact
| Field | Type |
|---|---|
Addr1, Addr2, City, State, Country, Postal | string |
Phone1, Phone2, Phone3 | string |
Email, WebComments | string |
Country and State also select the default template on insert — see above.
Shipping (requires "ShipName": "" — see the conditional above)
ShipName, ShipAddr1, ShipAddr2, ShipCity, ShipState, ShipCountry, ShipPostal, ShipPhone1
Commercial terms
| Field | Type | Notes |
|---|---|---|
Terms | string | |
DueDays | integer | |
Interest | integer | |
CreditLimit | number | |
PriceSchedule | integer | |
ContractDate | ISO 8601 | |
Salesman | integer | Salesperson unique |
Department | integer | Also selects the insert template |
CurrencyCode | integer | |
Balances | number | Written for the account's current department + currency |
Discounts
APDiscount (integer), APDiscountRule (integer enum), APDiscountDays (integer), AutoDiscount (single char), DisNv (integer), DisStat (integer)
Tax
| Field | Type | Notes |
|---|---|---|
TaxStatus | single char | |
TaxNumber, TaxCode | string | |
FedTax, GSTExempt, Duty, Foreign | single char | |
TaxBit1 .. TaxBit8 | present in the model |
Purchase orders
StandingPO (string), POExpiryDate (ISO 8601), POMaximumValue (number), POBilledSoFar (number, department + currency scoped)
Banking / EFT
BankInfo, EFTAccount, EFTBank, EFTName (all string)
Other
| Field | Type | Notes |
|---|---|---|
ECommerce | boolean | |
Password | string | Written to the account password field |
RetailType, CSType | single char | |
LastVisit | ISO 8601 | |
TimeZone | integer | |
ShipNo | integer | Maps to the account's FinancedBy field |
SearchContract | integer | Maps to SearchContact |
Contacts | array | Child records; no result entries |
FreeFormGroup | array | Free-form values by id |
WarningComments, LookupWords | string | Accepted but never written |


