Skip to main content

Sync customers and partners

ARC keeps customers and suppliers in one record type, Partner. A sync that keeps a customer or supplier master up to date in your system works through the same operations for both. This page covers those operations.

Everything here is GraphQL, called with a staff login. A server-to-server connector holding a tenant API key can read customers, but not suppliers, through GET /api/service/customers and GET /api/service/customers/:id; see Read endpoints. Creating or changing a partner has no REST route. For how to obtain a credential, see authentication. The tenant comes from that credential, so do not send an organization id. See tenant and permissions. Every field and argument is listed in the API reference.

1. One master for customers and suppliers​

A Partner is the one record for a customer, a supplier, or both. The relationship lives in roles, which takes these PartnerRole values: CUSTOMER, DISTRIBUTOR, MANUFACTURER, RESELLER and SUPPLIER.

  • A row can hold more than one role. A row with both CUSTOMER and SUPPLIER is a dual-role partner.
  • The supplier role is SUPPLIER. Use it for the supply side.
  • code is unique within your organization. Codes that ARC generates start with C for a row that has CUSTOMER, and with S for a supply-side row.
  • A customer row carries a customer account, which holds its document currency and tax-exemption details. A supplier row carries a vendor account, which holds its vendor code, payment terms and ratings.

type is the business type of the partner, such as BUSINESS or INDIVIDUAL. It does not say whether the partner is a customer or a supplier. Always write the relationship in roles. A role-shaped value sent in type (for example CUSTOMER, SUPPLIER or VENDOR) is silently rewritten to BUSINESS.

Permissions follow the roles on the row, not the caller's role name:

  • A row with CUSTOMER needs customers:customer:<action>.
  • A row with SUPPLIER, DISTRIBUTOR, MANUFACTURER or RESELLER needs suppliers:supplier:<action>.
  • A row with both kinds of role needs both permissions.
  • A row with no roles needs customers:customer:<action>.

On update, the check uses the roles the partner already has, together with the roles in your input.

A sales document needs a counterparty that holds CUSTOMER, RESELLER or DISTRIBUTOR. A purchase document needs one that holds SUPPLIER, DISTRIBUTOR or MANUFACTURER. This check runs only when a document is created, so existing documents keep working if a partner's roles change later. A partner with no roles recorded is not blocked by it. If the check fails, the error starts Partner "<name>" is not a customer (or is not a supplier), lists the partner's roles and the roles the document accepts, and asks you to add the role or pick a different partner.

togglePartnerRole cannot remove a partner's last role. The error is Partner must have at least one role.

2. Create and update a partner​

Use createPartner and updatePartner for sync. One call covers customers and suppliers, addresses, contacts, tax exemption and sales-rep ownership. createCustomer and updateCustomer are older, customer-only operations. Use the partner operations for new integrations.

  • createPartner(createPartnerInput: CreatePartnerInput!): Partner!. Requires customers:customer:create or suppliers:supplier:create, depending on the roles you send.
  • updatePartner(updatePartnerInput: UpdatePartnerInput!): Partner!. Requires customers:customer:update or suppliers:supplier:update, checked against the roles the partner already has together with the roles in your input.
  • removePartner(id: ID!): Partner!. Requires customers:customer:delete for a customer row and suppliers:supplier:delete for a supply-side row. A dual-role row needs both.
  • togglePartnerRole(id: ID!, role: PartnerRole!): Partner!. Requires the update permission for each family the row carries, plus the one for the role you toggle.
  • convertPartnerToCustomer(id: ID!): Partner! and convertPartnerToSupplier(id: ID!): Partner!. Require the update permission for each family the row already carries, plus the one for the role being added.

CreatePartnerInput requires name, paymentTerms and roles. UpdatePartnerInput requires id and makes every other field optional. It also adds preferredCurrency, preferredLanguage and taxExemption.

Example A creates a customer with one billing address and one email contact:

mutation CreatePartner($input: CreatePartnerInput!) {
createPartner(createPartnerInput: $input) {
id
code
name
roles
status
paymentTerms
creditLimit
taxId
assignedSalesRepId
customerAccount { preferredCurrency }
addresses { id type street1 city postalCode country isDefault isActive }
contacts { id type value contactName referenceId }
}
}
{
"input": {
"name": "Example Buyer Ltd",
"roles": ["CUSTOMER"],
"code": "<your-stable-external-code>",
"paymentTerms": "<payment-term-code>",
"taxId": "<tax-id>",
"addresses": [
{ "type": "BILLING", "street1": "<street>", "city": "<city>", "state": "<state>", "postalCode": "<postal-code>", "country": "<country>", "isDefault": true }
],
"contacts": [
{ "type": "EMAIL", "value": "buyer@example.com", "contactName": "Example Buyer", "isDefault": true }
]
}
}

Replace each placeholder with your own value. For paymentTerms, use a code returned by the paymentTerms query (see Section 5). A code the organization does not offer is refused (Section 5).

Codes​

A code you send is stored as given, so you can use a stable code from your own system. If you leave it out, ARC generates the next one. If a code you send is already used by another partner in the organization, the write is refused with Partner code "{code}" is already used by another partner in this organization. Enter a different code, or leave it blank to have one generated. ARC retries clashes on the codes it generates itself, so you only see this error for codes you send.

Addresses and contacts​

A new address needs type, street1, city, state, postalCode and country. Address types are BILLING, DELIVERY, HOME, OFFICE, PICKUP, RETURN, SHIPPING and WAREHOUSE.

A new contact needs type and value. Contact types are ACCOUNTING, EMAIL, FAX, PHONE, SALES, SKYPE, SOCIAL_MEDIA, TECHNICAL, TELEGRAM, WAREHOUSE, WEBSITE and WHATSAPP.

ARC sets a contact's referenceId when the contact is created, and ignores one you send on create. The value identifies the person, so several contact rows for one person (email, phone) can share it. On update, send back the id and referenceId you read.

When a customer has an EMAIL contact, creating the customer also creates a buyer-portal login for that address. The buyer sets their own password through the portal's reset flow. If the email already belongs to a customer login in this organization, including a deleted one, the create is refused with A customer with the email "{email}" already exists in this organization.

One email address or mobile number belongs to one person on one customer. If you add or change a value that another person already holds, the write is refused with The email "{value}" is already used by {holder}. Each email and mobile number can belong to only one contact. The same wording applies to mobile numbers. A rep with a restricted view sees "another customer" in place of the holder's name. Only values you add or change are checked. Existing duplicates do not block an update to other fields.

To check before you save, use contactIdentityConflicts(input: ContactIdentityCheckInput!): [ContactIdentityConflictInfo!]!. It requires customers:customer:create or customers:customer:update. Send the emails and phones you are about to save. Add partnerId when you edit an existing customer, and referenceId when you edit an existing person, so their own values do not count as conflicts. An empty list means no conflict was found. Each conflict carries message, the exact sentence the save would be refused with. partnerId is null when the holder is outside your scope.

Example B checks an email before you save it:

query ContactConflicts($input: ContactIdentityCheckInput!) {
contactIdentityConflicts(input: $input) {
channel
value
source
message
samePartner
partnerId
partnerCode
partnerName
contactName
}
}
{
"input": { "emails": ["buyer@example.com"], "phones": [], "partnerId": "<partner-id>" }
}

Tax exemption and currency​

preferredCurrency and taxExemption can only be set on a row that holds CUSTOMER. Setting them on any other row is refused.

A new customer takes the organization's base currency. If the organization has no base currency configured, creating a customer fails with Cannot create a customer: organization has no base currency configured. Complete currency setup before creating customers.

taxExemption takes taxExempt (required), certificateNumber, reason, validFrom and validTo. The rules are:

  • A customer flagged as exempt needs a certificate number.
  • The validity window cannot be inverted.
  • Dates are calendar days in YYYY-MM-DD form and must be real days. A date such as 2026-02-31 is refused.
  • Blank values are stored as null.
  • Each time you send taxExemption, every field is replaced with what you sent.

taxId, registrationNumber and legalName are plain strings. ARC does not check their format, so validate them on your side before you send them.

Updating a partner​

Partial updates are safe. UpdatePartnerInput has no create-time defaults, so sending only id and the fields you want to change leaves everything else alone. Example C changes one field.

mutation UpdatePartner($input: UpdatePartnerInput!) {
updatePartner(updatePartnerInput: $input) {
id
creditLimit
paymentTerms
assignedSalesRepId
updatedAt
}
}
{
"input": { "id": "<partner-id>", "creditLimit": 50000 }
}

The creditLimit value is a bare number in the organization's base currency. A negative value is refused. See Section 5 for how the limit is enforced.

caution

addresses and contacts on updatePartner are whole lists, not patches. If you send either one, send every row the partner should keep.

Both lists are reconciled in place. If you leave a list out of the input, it is not touched.

  • Addresses. ARC matches each address you send by id. Without an id, it matches on type, street1, city and postal code, ignoring case and leading or trailing spaces. A matched row is updated, and keys you leave out on it keep their current values, except the three listed below. Rows that match nothing are created. Live addresses missing from the array are soft-deleted. An empty array removes them all.
  • Contacts. ARC matches each contact you send by id, then by a referenceId that already belongs to this partner with the same type and value, then by type and value, ignoring case and leading or trailing spaces. A matched row is updated in place and keeps its id, and keys you leave out on it keep their current values, except the three listed below. Rows that match nothing are created. Live contacts missing from the array are soft-deleted, except a contact that belongs to an active buyer-portal login, which is kept. An empty array removes every other contact.

Three keys on address and contact rows have input defaults, so leaving them out does not leave them alone: isActive becomes true, isDefault becomes false and priority becomes SECONDARY. Send the current values of those three keys on every row.

Send the id and referenceId of every row you read, so edits land on the same rows.

Deleting a partner​

Deletion is soft. A partner with open orders cannot be deleted, and the error is Cannot delete partner with active orders. These statuses block deletion:

  • Purchase orders in DRAFT, SUBMITTED, APPROVED or PARTIALLY_RECEIVED.
  • Sales orders in DRAFT, CONFIRMED, PARTIALLY_INVOICED or INVOICED.

After a partner is deleted, reads do not return it.

3. Read and sync customers and partners​

There are three ways to read, and they suit different jobs.

  • Incremental customer sync: use customersPage.
  • Supplier lists, and customer or supplier lists with offset paging: use partners.
  • Small lists and pickers: use customers.

The reads in this section need a staff login but no permission string. customersPage, customers, customer, partners, partnersCount and partner are limited to your reporting scope. A caller holding analytics:organization:read sees every record. Anyone else sees only partners assigned to, or created by, themselves or a user in their reporting subtree. That limit applies to supplier rows in partners too. Filters can only narrow what you see. A record outside your scope returns the same not-found error as an id that does not exist.

  • customersPage(cursor: String, enteredBy: String, search: String, take: Int = 100, updatedAfter: DateTime): CustomersPage!. Cursor pagination. Items are Customer, and only customers are returned. take is limited to 1 through 100. Pages come back in a stable order, by last update time and then id. nextCursor is null on the last page. Treat the cursor as opaque, and do not decode or build it. Search matches the name, code, display name, legal name, and contact value or name. With updatedAfter, the call returns records changed since that time, and it includes deleted records with deletedAt set, so you can remove your local copies. For the document-header lookup, see Read the catalog and resolve prices.
  • customers(enteredBy: String, search: String): [Customer!]!. An unpaged list, suited to small tenants and pickers.
  • customer(id: ID!): Customer!.
  • partners(filter: PartnerFilterInput, skip: Int, sort: PartnerSortInput, take: Int): [Partner!]!. Offset paging, returned as a plain list. Use roles to narrow it to customers or suppliers. This is also the supplier list.
  • partnersCount(filter: PartnerFilterInput): Int!. The total for the same filter and scope. Use it to work out how many pages skip and take need.
  • partner(id: ID!): Partner!. The only read that returns ledger.
  • suppliers and vendors return unpaged lists of Partner and are not limited by reporting scope. Prefer partners with roles set to SUPPLIER.

PartnerFilterInput takes these fields:

  • assignedSalesRepId. The string UNASSIGNED matches records with no owner.
  • roles
  • search
  • searchNameOnly, which matches name, code and tax ID only, not contact emails.
  • status: ACTIVE, INACTIVE or PENDING_APPROVAL.
  • tags, tierKey and type.

PartnerSortInput takes field (CODE, CREATED_AT, NAME, STATUS or UPDATED_AT) and order (ASC or DESC).

Example D pulls customers that changed since your last sync. Each page holds up to 100 records. Pass nextCursor back as cursor until it is null.

query CustomerDelta($cursor: String, $since: DateTime) {
customersPage(cursor: $cursor, take: 100, updatedAfter: $since) {
items {
id
code
name
status
roles
paymentTerms
creditLimit
assignedSalesRepId
taxId
updatedAt
deletedAt
customerAccount { preferredCurrency }
addresses { id type street1 city postalCode country isActive isDefault }
contacts { id type value contactName referenceId }
}
nextCursor
}
}

Example E lists active suppliers with a total for paging.

query Suppliers($skip: Int, $take: Int) {
partners(filter: { roles: [SUPPLIER], status: ACTIVE }, sort: { field: NAME, order: ASC }, skip: $skip, take: $take) {
id code name roles status paymentTerms taxId
}
partnersCount(filter: { roles: [SUPPLIER], status: ACTIVE })
}

Finding an existing record​

ARC has no field for your own system's id on a partner, so choose one of these patterns:

  • Set your own code. Use a stable id from your system as the code. If a retry after a timeout sends the same code, the write is refused with the code error from Section 2, so it cannot create a second row.
  • Look up first. Search by code with search, then check for an exact match on your side. Search is a "contains" match, not an exact one. Store ARC's id in your mapping table.

taxId and registrationNumber have no uniqueness rule. Do not rely on them to find duplicates.

createPartner and createCustomer take no idempotency key in the GraphQL schema. After a timeout, look up the record by code before you try to create it again. For the general pattern, see errors, pagination, and idempotency.

4. Addresses, contacts, and branches​

Addresses and contacts are child rows of a partner. You write them through createPartner and updatePartner (Section 2). On the staff side there are no separate create or update operations for addresses or contacts.

Address fields include isActive, isDefault, isDualPurpose, priority (BILLING, PRIMARY, SECONDARY or SHIPPING) and role. They are available on input and output.

Orders point at addresses. CreateSalesOrderInput takes branchAddressId, and it also takes billingAddress and shippingAddress as objects. Keep address ids stable, and never delete and recreate an address that an order refers to.

A partner has no single billing address field. Use rows with type set to BILLING, SHIPPING or DELIVERY, and mark the one you want with isDefault and priority. This page does not say which address an order picks by default. When the address matters, pass it on the order.

A customer's branches are ordinary addresses. The branch settings, such as order limits, are changed through buyer-side operations. Those are out of scope for this page.

On the staff side, adminSetAddressActive(addressId: ID!, isActive: Boolean!): Boolean! turns a SHIPPING or DELIVERY address on or off. It is gated by tenant-admin status, not a permission string: me.role must be ADMIN or OWNER, or the caller must hold an active assignment to the organization's role named Admin or Owner. For any other address type, it returns Address not found. Turning an address on also activates its customer if the customer is still PENDING_APPROVAL.

5. Payment terms and credit​

Payment terms​

paymentTerms on a partner is a code that the tenant defines. It is a string, not an enum. Codes match ^[A-Z][A-Z0-9_]{1,31}$. Read the list first and use a code from it. A partner write checks the code against the terms the organization offers:

  • A malformed code is refused with a message that starts "<code>" is not a valid payment-term code.
  • A code the organization does not offer, or a deactivated one, is refused with a message that starts Payment term "<code>" is not available for this organization and lists the available codes.
  • On update, re-sending the code the partner already holds is accepted even if that term has since been deactivated. A code you newly choose must be an active term.

paymentTerms (the query) needs a staff login and one of these permissions: sales:quote:read, sales:sales_order:read, sales:sales_invoice:read, payments:invoice:read, purchase:purchase_order:read, customers:customer:read, suppliers:supplier:read or settings:tenant:read. Which credential types can call it is covered in the REST API guide.

If the tenant has not set up its own terms, the query returns the platform defaults. Those have isPlatformDefault set to true and no stored id.

  • createPaymentTerm(input: CreatePaymentTermInput!). Requires settings:payment-term:create.
  • updatePaymentTerm(input: UpdatePaymentTermInput!). Requires settings:payment-term:update. The code is set at creation and cannot change here.
  • setDefaultPaymentTerm(id: ID!). Requires settings:payment-term:update.
  • deactivatePaymentTerm(id: ID!). Requires settings:payment-term:delete. This deactivates the term and does not delete it. Documents that already use the term keep showing it.
  • materializePaymentTerms. Requires settings:payment-term:create. It is idempotent, and it turns the platform defaults into editable rows.

A payment term's kind is COD, NET or PREPAID. A due date is the invoice date plus netDays.

Example F lists the terms available to the tenant.

query PaymentTerms {
paymentTerms {
id
code
label
kind
netDays
isDefault
isActive
isPlatformDefault
}
}

Credit​

creditLimit is in the organization's base currency. Send it as a bare number.

  • A creditLimit of zero or less means no limit. The check is skipped.
  • A negative value is refused on create and on update with Credit limit cannot be negative. Leave it empty or 0 for no limit.
  • On sales orders, the limit is checked when an order on credit payment terms moves into CONFIRMED, from DRAFT or from AWAITING_PAYMENT. What decides is the order's payment terms, not the status it came from. Today the check recognises the codes NET_15, NET_30, NET_45, NET_60 and NET_90 as credit terms. An order on any other code, including prepaid and cash-on-delivery terms, is not checked on the way into CONFIRMED.
  • Creating a sales order directly as CONFIRMED is also checked, whatever its payment terms.
  • A sales invoice created without a linked sales order, including a quote converted straight to an invoice, is also checked. Invoicing an order that was already confirmed is not checked again.
  • The exposure that is checked is the open sales invoice balance, plus credit-terms orders that are confirmed but not yet invoiced, plus the new document. All of it is summed in the base currency.
  • If the document would go over the limit, the mutation is refused with a message that ends Requires the 'orders:sales-order:override-credit-limit' permission to override. A caller holding orders:sales-order:override-credit-limit can proceed, and the override is audited. The tenant ADMIN role does not grant the override.
  • The organization's creditLimitEnabled flag, set with updateOrganization, switches the check off for the whole organization. A limit on a record does not prove that it is enforced. See workspace administration.

For how the credit error appears in quote and order flows, see quote to cash.

The customer account's creditLimit, currentBalance and availableCredit are read from the partner record. They are not separate settings, so write credit on the partner only.

currentBalance is a netted, signed figure. For a dual-role partner it is shared across the customer and supplier sides. Do not read it as what a customer owes. availableCredit is the limit minus currentBalance, and it can be negative for an over-limit customer. It has no useful meaning for an unlimited customer.

For the amounts a partner owes or is owed, read partner(id).ledger. It holds asCustomer and asSupplier positions, each with outstanding, overdue, creditOnAccount, advances and noteCredits.

adjustCustomerCredit changes a customer's credit without a full update:

mutation AdjustCredit($customerId: String!, $type: String!, $amount: Float!, $reason: String!) {
adjustCustomerCredit(customerId: $customerId, type: $type, amount: $amount, reason: $reason) {
id
code
}
}

It requires customers:customer:manage. type takes INCREASE_LIMIT, DECREASE_LIMIT, ADJUST_BALANCE or PAYMENT_RECEIVED. Any other value is refused with Invalid adjustment type: {type}. DECREASE_LIMIT subtracts from the limit and does not go below zero. PAYMENT_RECEIVED does not take the balance below zero. To set an absolute limit, use updatePartner with creditLimit.

caution

A DECREASE_LIMIT that reaches zero turns the limit off, so the customer becomes unlimited. Do not use it to set a limit to a specific value.

6. Sales-rep ownership, visibility, and customer approval​

Owning rep​

assignedSalesRepId is set on create and update. The assignedSalesRep field returns the rep's id, firstName, lastName, email and role. previousSalesRep, previousSalesRepId and repReassignedAt record the handoff when ownership changes.

A rep is optional when you activate a customer.

caution

A rep or manager who edits a customer within their own scope has assignedSalesRepId ignored. The write does not fail. If your integration uses a rep-scoped login, owner changes will not take effect, and nothing will tell you. A rep who creates a customer always becomes that customer's owner.

Customer approval​

When the organization requires customer approval (its requireCustomerApproval setting, changed with updateOrganization), a new customer starts as PENDING_APPROVAL. The exception is a creator who is a tenant admin (me.role is ADMIN or OWNER, or an active assignment to the role named Admin or Owner), or who holds partners:customer:approve: their customers start as created. Customers who register through the buyer app or the customer portal always start as PENDING_APPROVAL.

Customer statuses are ACTIVE, INACTIVE and PENDING_APPROVAL.

The operations below are gated by tenant-admin status, not by a permission string. The caller qualifies when me.role is ADMIN or OWNER, or when they hold an active assignment to the organization's role named Admin or Owner (see userRoles in workspace administration). No permission grants them. These operations differ from the role-gated reference-data and API-key operations, which match me.role exactly and do not accept OWNER.

  • activateCustomer(id: ID!, assignedSalesRepId: ID): Customer! activates a customer, and can assign the rep in the same call.
  • deactivateCustomer(id: ID!): Customer!.
  • The pending-customer operations: pendingCustomersSummary, pendingCustomersByRep, bulkActivatePendingCustomers, bulkRejectPendingCustomers, bulkAssignPendingCustomers, assignableCustomers and assignCustomersToRep.

Example G activates a pending customer.

mutation ActivateCustomer($id: ID!, $repId: ID) {
activateCustomer(id: $id, assignedSalesRepId: $repId) {
id
code
status
assignedSalesRepId
}
}

A customer that is not ACTIVE cannot have a sales order confirmed, or created directly as CONFIRMED. The error reads <name> is <status> and cannot have orders confirmed until an admin activates them (or a user holding 'partners:customer:approve' confirms). A user holding partners:customer:approve can confirm the order anyway. That permission does not let its holder activate a pending customer: activation needs tenant-admin status, as described above.

Bulk activate, reject and assign are capped at 500 customers per call. A larger call is refused with Bulk actions are limited to 500 customers per call (got <n>). ARC applies each row on its own. If another admin has already handled a customer, that row is reported as skipped.

Do not confuse this with order approvals

Customer approval is about onboarding a new customer. A different flow handles over-limit checkout requests raised by a buyer account. Those use partnerOrderApprovals, which requires customers:customer_user:read, and partnerApproveOrderRequest and partnerDeclineOrderRequest, which require sales:sales_order:approve. Buyer-side approval operations are out of scope for this page.

Integration rules​

  • Use roles, not type, to say whether a partner is a customer or a supplier.
  • customers and customersPage return Customer. partners, suppliers and vendors return Partner.
  • Do not send an organization id. The tenant comes from your credential.
  • Do not rely on an idempotency key for createPartner or createCustomer. Use a stable code and look up the record before you create it again.
  • Do not treat a code search as an exact match. Confirm it on your side.
  • Do not send a partial addresses or contacts array to updatePartner. Rows you leave out are removed. Send the full list, or leave the field out.
  • Do not delete and recreate addresses or contacts. Send the id and referenceId you read, and the current isActive, isDefault and priority.
  • Do not read currentBalance as the amount a customer owes. Use partner(id).ledger.
  • Do not treat creditLimit of zero as "no credit". It means no limit.
  • Do not expect a credit check when a quote is created or an order-linked invoice is raised. The check runs when a credit-terms sales order is confirmed, when an order is created as CONFIRMED, and when an invoice is created with no sales order behind it.
  • Do not treat a refusal because the customer is not ACTIVE as a temporary failure. It needs an operator to act.
  • Do not rely on a rep-scoped login to change an owner. The change is ignored without an error.
  • Do not assume you can see every customer. A rep-scoped login gets a limited list, and records outside its scope look like missing ones.
  • Do not send a payment-term code the organization does not offer. It is refused.
  • Do not hard-code a payment-term code or a currency. Read paymentTerms, and take a customer's document currency from its customer account or from the organization's base currency.
  • Do not reuse an email address across contacts or customers. One address belongs to one person on one customer.
  • Do not call buyer-side operations. They need a buyer login.
  • Do not delete a partner that has open orders. The deletion is refused.

Next: Quote to cash.