# 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](#loyalty-fields). It builds on [B2B / ERP integration](/guides/b2b-erp-integration), which covers keys, upserts by your own id, polling and webhooks.

## How the rules fit together

- Every rule but `costCentresManagedExternally` starts 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](#company-overrides-b2bsettings) 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/companies` route: reads always work, and writes answer `403` with `code: "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](/guides/b2b-orders#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.

```http
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
  }
}
```

```json
{
  "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 `null` goes 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](/guides/b2b-orders#creating-company-orders-through-the-api) 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 with `includeRemoved=true`) and needs both `company:list` and `company: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: read `GET /v1/customers?updatedFrom=` for those;
- the `contacts` of `GET /v1/companies/{id}`;
- the answer to `POST /v1/companies/{companyId}/users` and to a `PATCH` on a user. `DELETE` answers only `{ "id": ... }`, and `POST .../users/{contactId}/send-activation` answers `{ "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](/guides/b2b-erp-integration#provisioning-buyers-silently-then-activating-them-in-bulk)). 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](/guides/b2b-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.

```http
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. |

```json
{
  "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`) on `GET /v1/companies/{companyId}/locations`, on `GET /v1/companies/{companyId}/locations/{locationId}` and in the `locations` of `GET /v1/companies/{id}`.
- Write them (`company:update`) with `POST /v1/companies/{companyId}/locations`, `PATCH /v1/companies/{companyId}/locations/{locationId}` and `DELETE /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](/guides/b2b-orders#approvals).
- The budget codes a user may charge: [Cost centres](/guides/b2b-cost-centres).
- Every column of the company file, for bulk loads: [CSV imports and exports](/guides/csv-imports#the-company-file).
