Companies, buyers and locations
A company is a business that buys from the store on trade terms. Its company users are the people who sign in to the storefront and order on its behalf. Its locations are its branches: the addresses it bills and delivers to, each with the price catalogue and payment term that apply to orders sent there.
This page lists the fields an integration reads and writes on those three resources, including the credit, approval and checkout rules a company can carry. Companies also carry three loyalty fields that are not in use yet: see Loyalty fields. It builds on B2B / ERP integration, which covers keys, upserts by your own id, polling and webhooks.
How the rules fit together
- Every rule but
costCentresManagedExternallystarts as a store setting the merchant manages in the dashboard under Settings > Selling > B2B. Each feature is off until the merchant turns it on, so a store that never opens that page behaves exactly as before; the overrides table gives each setting's store default. - A company can override those settings for itself in
b2bSettings. A key that is absent inherits the store setting. The store's other B2B settings (per-user limits, the longest delivery instructions allowed, company loyalty and the account area extras) apply to every company alike. The store settings themselves are not on the public API. - A company user can narrow the company's rules further with the per-user fields below.
- These fields sit behind the store's B2B feature like every
/v1/companiesroute: reads always work, and writes answer403withcode: "FEATURE_FROZEN"when the store's plan does not include B2B.
Company fields
Read them on GET /v1/companies, on GET /v1/companies/{id}, and in the answer to a PATCH or an upsert (which answers { "company": { ... }, "created": true }). The answer to POST /v1/companies carries the fields it set and those with a default; read the company again for the rest. DELETE /v1/companies/{id} answers only { "id": ... }. Write them with PATCH /v1/companies/{id} (company:update).
| Field | Type | Default | Meaning |
|---|---|---|---|
creditLimit | number or null | null | The most the company may owe on account at once, in the store's primary currency. null means no limit. |
onStop | boolean | false | Stops pay on account for every user of the company. Paying by card still works. |
statusNote | string, up to 500 characters, or null | null | A short note the storefront shows the company's users with their account details, for example why the account is on stop. |
approvalThreshold | number or null | null | Orders over this total, in the store's primary currency, are held for approval when order approval is on. null means no value trigger. |
approvalNonContract | boolean | false | Hold an order for approval when it contains a product the company's catalogue does not price. |
authoriserContactId | uuid or null | null | The company user asked first to decide a held order, when they can decide it. |
secondAuthoriserContactId | uuid or null | null | The company user asked instead when the authoriser cannot decide an order, and copied in when both can. |
b2bSettings | object | {} | The company's overrides of the store settings: see below. |
The two authorisers decide only who is asked. Neither role gives a right to decide on its own: any active user of the company with canApproveOrders and an approvalLimit that covers the order's total may decide it. See Who decides. Removing a company user also clears authoriserContactId, secondAuthoriserContactId and mainContactId wherever they name that user. The same happens when the customer behind the user is deleted or has their personal data erased. Merging that customer into another keeps the fields on the person: merged into a customer who belongs to no company, the user moves to that customer and the fields keep pointing at it; merged into another user of the same company, the fields move to that user, who keeps the first authoriser slot and leaves the second empty when the merge would put them in both. A customer belongs to one company at most, so a merge into a user of another company is refused.
On PATCH, a field you leave out keeps its stored value and a field you send is set. null clears a field whose type above includes null, and the company's own externalId, taxId, note and mainContactId. A field that can never be empty refuses null with 400 and a message naming it, such as "onStop cannot be null: leave it out to keep the current value.": name, status, onStop, approvalNonContract, loyaltyMultiplier, tags (send [] to clear the tags) and b2bSettings itself (send {}; inside b2bSettings, a key sent as null goes back to the store setting, as below). Both authorisers must be users of this company. Any other id answers 400 with errorCode: "B2B_CONTACT_NOT_IN_COMPANY".
POST /v1/companies and PUT /v1/companies/by-external-id/{externalId} take the company's own fields (name, status, externalId, taxId, note) together with every rule above, so an accounts system sets a company up in one idempotent call. status, onStop, approvalNonContract, loyaltyMultiplier and b2bSettings refuse null there too, with a message saying what leaving the field out does, such as "status cannot be null: leave it out to use the default." on POST and "status cannot be null: leave it out to keep the current value, or the default for a new company." on the upsert. The one exception is the two authorisers: a company being created has no users yet, so naming one answers 400 with errorCode: "B2B_CONTACT_NOT_IN_COMPANY". Create the company, add the user, then name the authoriser with a later PATCH or upsert. Both need name on every call, the upsert too when the company exists, and the upsert writes it. The upsert matches externalId ignoring case and surrounding spaces, and the company takes your spelling of it from the path.
An externalId belongs to one company of the store, ignoring case. A create, or a change of a company's externalId, that another company already holds in any casing answers 409 with errorCode: "B2B_COMPANY_EXTERNAL_ID_TAKEN". Where two older companies hold one id in different casings, the upsert by that id answers the same code, naming the clash, rather than guessing which to update: give one of them another id.
PATCH /v1/companies/6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"creditLimit": 25000,
"statusNote": null,
"approvalThreshold": 5000,
"authoriserContactId": "9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a",
"b2bSettings": {
"orderApprovalEnabled": true,
"poNumberMode": "required",
"poNumberFormat": "PO-#####",
"payOnAccountEnabled": null
}
}{
"statusCode": 200,
"message": "Success",
"data": {
"id": "6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e",
"name": "Beacon Hardware Ltd",
"externalId": "CUST-004821",
"status": "active",
"creditLimit": 25000,
"onStop": false,
"statusNote": null,
"approvalThreshold": 5000,
"approvalNonContract": false,
"authoriserContactId": "9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a",
"secondAuthoriserContactId": null,
"b2bSettings": { "orderApprovalEnabled": true, "poNumberMode": "required", "poNumberFormat": "PO-#####" },
"updatedAt": "2026-10-08T10:15:02.000Z"
},
"timestamp": "2026-10-08T10:15:02.120Z"
}(Excerpt: the answer carries every company field, the loyalty fields included, but not the company's locations and contacts, and tags only when you sent them.) A PATCH that changes at least one field fires company.updated; one that changes nothing fires nothing.
Loyalty fields
The API takes and answers three company loyalty fields on POST, PATCH and the upsert:
| Field | Type | Default | Meaning once company loyalty is in use |
|---|---|---|---|
loyaltyMultiplier | whole number from 1 to 3 | 1 | Multiplies the points the company's orders earn. It refuses null. |
loyaltyPotMode | shared, per_user or null | null, read as per_user | Whether the company's users share one points balance. |
loyaltyRedeemRoles | managers, managers_authorisers, everyone or null | null, read as managers_authorisers | Who may spend the points. |
Company loyalty is not in use yet: no points are earned, pooled or spent through these fields, so the values you set change nothing for now.
Company overrides: b2bSettings
b2bSettings holds only what this company does differently from the store. It merges key by key:
- a key you send with a value overrides the store setting for this company;
- a key you send as
nullgoes back to the store setting; - a key you leave out stays as it is.
The payOnAccountEnabled: null in the example above removed the company's own choice, which is why the answer no longer lists it.
| Key | Values | Store default | What it decides |
|---|---|---|---|
orderApprovalEnabled | boolean | false | Whether orders can be held for approval at all. |
overSpendingLimit | refuse or approval | refuse | An order over a user's spendingLimit: refuse it, or hold it for approval. |
overCreditLimit | refuse_account or approval | refuse_account | An on-account order that would take the company over its creditLimit: refuse pay on account (the buyer can still pay by card), or place it on account and hold it for approval. |
approvalReminderHours | integer from 1 to 168 | 24 | How many hours a held order waits before its authoriser is reminded. |
poNumberMode | off, optional or required | off | The purchase order number at checkout. |
poNumberFormat | text, up to 64 characters | none | A mask the PO number must match. |
poNumberUnique | boolean | true | Whether a PO number may be used only once per company (cancelled orders do not count). Checked only when an order has a PO number. |
costCentreMode | off, optional or required | off | A cost centre on every order line. |
costCentresManagedExternally | boolean | false; not a store setting, so set it on each company | The company's cost centre list is read-only in the dashboard and the storefront, and only the API writes it. |
payOnAccountEnabled | boolean | false | Pay on account at checkout. |
branchPickerEnabled | boolean | false | The buyer picks the branch (company location) the order is for. |
deliveryInstructionsEnabled | boolean | false | Delivery instructions on the order. |
leadTimePerLineEnabled | boolean | false | A lead time and an expected date on each order line. |
newAddressAtCheckoutDefault | boolean | true | Whether users may type a new delivery address at checkout when their own mayEnterNewAddress is null. It applies only while the store's per-user limits are on; until then every user may. |
A PO mask uses # for a digit, A for a letter and * for any character; every other character must appear exactly as written, case included. PO-##### accepts PO-48213 and refuses PO-4821 and po-48213. A mask with a control character answers 400 with errorCode: "B2B_PO_FORMAT_INVALID". A mask longer than 64 characters is refused with a plain validation 400, which carries no errorCode.
Company users
Every company user object carries its id, companyId, customerId (the customer record behind the user, which is what a company order names), customer (its name and email), permissions, createdAt, updatedAt and removedAt (set only on a removed user), and the fields below. You read them on:
GET /v1/companies/{companyId}/users(company:read);GET /v1/company-users, which lists the users of every company added, changed or removed in a window (removed ones withincludeRemoved=true) and needs bothcompany:listandcompany:read. Each user carries its customer's id, names and email as they are now, but a change to the customer alone does not move the user in this feed: readGET /v1/customers?updatedFrom=for those;- the
contactsofGET /v1/companies/{id}; - the answer to
POST /v1/companies/{companyId}/usersand to aPATCHon a user.DELETEanswers only{ "id": ... }, andPOST .../users/{contactId}/send-activationanswers{ "sent": true }.
Write them with PATCH /v1/companies/{companyId}/users/{contactId} (company:update). POST /v1/companies/{companyId}/users creates a user with only email, firstName, lastName, role or permissions, spendingLimit, sendActivationEmail and mainContact (see Provisioning buyers silently). Set the rest with a PATCH once the user exists. The first user of a company that has no main contact becomes it, whatever mainContact says. Each store has its own sign-ins: the new user gets a sign-in on this store, and the same email signing in to an Orbit dashboard or to another store neither blocks it nor is joined to it.
POST .../users refuses an email it cannot use with 409:
errorCode (409) | Cause |
|---|---|
B2B_ALREADY_IN_COMPANY | The customer with this email already belongs to a company of the store, this one included. |
B2B_EMAIL_HAS_PERSONAL_ACCOUNT | The email has a personal account on this store. |
B2B_EMAIL_HAS_LOGIN | The email already has a sign-in on this store, activated or not: send them an invitation instead, which they accept by signing in with it. |
B2B_EMAIL_LOGIN_DELETED | The email belonged to a sign-in on this store that was deleted, or that its owner closed. For a closed one, availableFrom is when the email can be used again. |
| Field | Type | Default | Meaning |
|---|---|---|---|
status | active or inactive | active | inactive switches the user off: they cannot sign in or place orders, and their order history, favourites and assignments stay. |
spendingLimit | number or null | null | The largest order total the user may place, in the store's primary currency. null means no limit. |
overSpendingLimit | refuse, approval or null | null | What happens when this user goes over spendingLimit. null follows the company. |
canApproveOrders | boolean | false | The user may approve and decline the company's held orders. |
approvalLimit | number or null | null | The highest order total the user may decide, in the store's primary currency. null means no limit. |
payOnAccount | boolean or null | null | false takes pay on account away from this user; null and true allow it. |
mayEnterNewAddress | boolean or null | null | Whether the user may type a new delivery address at checkout. null follows the company, then the store. |
visibilityCatalogId | uuid or null | null | A restricted catalogue that limits what this user sees and buys. Prices stay the company's. |
defaultBillingLocationId | uuid or null | null | The billing location preselected at checkout. |
defaultShippingLocationId | uuid or null | null | The delivery location preselected at checkout. |
allowedLocationIds | array of uuid | [] | The company locations the user may deliver to; at most 200. An empty list (or null on a PATCH) means every location. |
allowedCostCentreIds | array of uuid | [] | The cost centres the user may charge. An empty list (or null on a PATCH) means every active cost centre of the company. |
On PATCH, null clears a field whose type above includes null, and sends allowedLocationIds or allowedCostCentreIds back to every one. permissions, status and canApproveOrders refuse null with 400, naming the field.
allowedLocationIds, the two default locations, visibilityCatalogId and mayEnterNewAddress take effect only while the store's per-user limits are on (Settings > Selling > B2B). Until then every user may use every company location and sees what the company sees.
PATCH /v1/companies/6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e/users/9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"canApproveOrders": true,
"approvalLimit": 10000,
"overSpendingLimit": "approval",
"allowedLocationIds": ["2b7d4e6f-1a3c-4b5d-8e9f-0a1b2c3d4e5f"],
"defaultShippingLocationId": "2b7d4e6f-1a3c-4b5d-8e9f-0a1b2c3d4e5f"
}errorCode (400) | Cause |
|---|---|
B2B_LAST_MANAGER | Switching off the company's last active user who can add, remove or manage users, or its last active user who can manage permissions (can_manage_permissions), when it has one. Give another user that permission first. The message says which: "The company must keep at least one active user who can manage users." or "...who can manage permissions." |
B2B_LOCATION_NOT_IN_COMPANY | An id in allowedLocationIds, or a default location, is not a location of this company. A deleted location the user already holds may stay, so a user read with GET writes back unchanged. |
B2B_COST_CENTRE_NOT_IN_COMPANY | An id in allowedCostCentreIds is not a cost centre of this company. |
B2B_CATALOG_NOT_FOUND | visibilityCatalogId is not a catalogue of this store. |
B2B_CATALOG_NOT_RESTRICTED | The catalogue is not restricted; only a restricted catalogue can limit what a user sees. |
{
"statusCode": 400,
"timestamp": "2026-10-08T10:15:02.120Z",
"path": "/v1/companies/6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e/users/9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a",
"method": "PATCH",
"message": "The company must keep at least one active user who can manage users.",
"errorCode": "B2B_LAST_MANAGER"
}A plain validation 400 has no errorCode, and its message is a list of the problems found.
Adding a user fires company_user.created and removing one company_user.removed. A PATCH that changes a user fires company_user.updated, switching off and back on included; one that changes nothing fires nothing. A change made from the cost centre's side, through its userIds, fires only the cost_centre.* topic, and deleting a company removes its users with only company.deleted.
Locations
A location is one of the company's branches.
- Read them (
company:read) onGET /v1/companies/{companyId}/locations, onGET /v1/companies/{companyId}/locations/{locationId}and in thelocationsofGET /v1/companies/{id}. - Write them (
company:update) withPOST /v1/companies/{companyId}/locations,PATCH /v1/companies/{companyId}/locations/{locationId}andDELETE /v1/companies/{companyId}/locations/{locationId}.
| Field | Type | Meaning |
|---|---|---|
name | string | The branch name buyers see at checkout. It cannot be all or none, in any casing: the CSV files give those words a meaning. |
billingAddress, shippingAddress | object or null | line1, line2, city, stateId, countryId, postalCode, phone. An address you send replaces the stored one whole. |
externalRef | string, 1 to 64 characters on one line without a semicolon, or null | Your reference for the branch, unique within the company, ignoring case. It cannot be all, none or default, in any casing: the CSV files give those words a meaning. |
deliveryInstructions | string or null | Standing instructions for every delivery to this branch. Emails and documents print them before the order's own instructions. |
poRequired | boolean or null | While the company's poNumberMode is optional or required, true makes a PO number compulsory on orders for this branch and false lifts it; null follows the poNumberMode. While it is off, PO numbers are not used and this field changes nothing. |
isDefault, catalogId, paymentTermsId, taxExempt | read only here | Set the catalogue with PATCH /v1/companies/{companyId}/locations/{locationId}/catalog (b2b-catalog:update) and the payment term with PATCH /v1/companies/{companyId}/locations/{locationId}/payment-term (payment-term:update). |
On PATCH, null clears externalRef, deliveryInstructions, poRequired or an address, and name refuses it with 400, naming the field.
Every company is created with one location, its default, and the default location cannot be deleted: DELETE on it answers 400 with errorCode: "B2B_CANNOT_DELETE_DEFAULT_LOCATION". A second location with the same externalRef in one company answers 409 with errorCode: "B2B_LOCATION_EXTERNAL_REF_TAKEN".
Upserting a branch by your reference
PUT /v1/companies/{companyId}/locations/by-external-ref/{externalRef} creates the location with that reference, or updates it, in one call. The reference matches ignoring case and surrounding spaces, and the location takes your spelling of it from the path. An externalRef in the body is accepted and ignored without being checked; the rest of the body is checked as usual. It never changes the default location, and repeating a call that changes nothing, the same address included, writes nothing: no save, no webhook and no new updatedAt. It answers { "location": { ... }, "created": true }, with false on an update. Use it to keep the branch list in step with your system without looking ids up first. The body needs name on every call, even when the location exists. A reference in the path that is not 1 to 64 characters on one line without a semicolon, or that is all, none or default in any casing, answers 400 with B2B_EXTERNAL_REF_INVALID; one in the body of a POST or PATCH gets a plain validation 400.
A location write that changes something fires company_location.created, company_location.updated or company_location.deleted, and moving the default fires company_location.updated for both locations. The default location a new company starts with fires only company.created, a tax exemption set in the dashboard fires company.updated, and the company file's Pricing Catalogue, Payment Terms and Tax Exempt cells change the default location without a company_location.updated. DELETE on a location answers only { "id": ... }. Which locations a user may use, and their defaults, are fields of the user.
Next steps
- The order fields a company order carries, and how approvals and pay on account work: Orders, approvals and pay on account.
- The budget codes a user may charge: Cost centres.
- Every column of the company file, for bulk loads: CSV imports and exports.