# B2B / ERP integration

Wire a store's B2B data — companies, buyers, locations, catalogues, payment terms, and net-terms invoices — to the system that actually owns it: an ERP, a CRM, or an accounting package. This guide covers the shape of that integration end to end: minting a credential, syncing companies both ways without creating duplicates, provisioning buyers without surprise emails, catching up on changes efficiently, and reacting to change in near real time.

It assumes you have already read [Store API keys](/guides/api-keys) and the [Scope Catalog](/scope-catalog) — this page builds on both rather than repeating them.

## Minting a key for your ERP

B2B integrations are almost always the "one store, one system I run" shape an [API key](/guides/api-keys) is built for — there is nothing to distribute and nobody but the merchant to consent. In the store dashboard, create a key under **Settings → API Keys** and grant it the scopes your integration actually uses:

| Scope family | Typical grant |
|---|---|
| `company:*` | Full CRUD — most ERP syncs create, read, and update companies, users, invitations, and locations. |
| `b2b-catalog:*` | `list` + `read` if your ERP only needs to know which price book a company is on; add `create`/`update`/`delete` if the ERP is the system of record for pricing. |
| `payment-term:*` | Same split as catalogues. |
| `b2b-invoice:list` + `b2b-invoice:read` | Invoices are read-only on the public API regardless — there is no write scope to grant. |
| `webhook:*` | If you want push updates instead of, or alongside, polling — see [Two-way sync with webhooks](#two-way-sync-with-webhooks) below. |

Every `/v1/companies`, `/v1/catalogs`, `/v1/payment-terms`, and `/v1/b2b-invoices` request also requires the store's plan to carry the B2B feature. See [FEATURE_FROZEN: reads keep working, writes do not](#featurefrozen-reads-keep-working-writes-do-not).

## Idempotent company upsert-by-external-id

An ERP sync that runs on a schedule needs to create a company the first time and update it every time after, without first checking whether it already exists. `PUT /v1/companies/by-external-id/{externalId}` does exactly that: it looks up the company by your own ERP id and creates or updates in one call.

```http
PUT /v1/companies/by-external-id/CUST-004821 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "name": "Beacon Hardware Ltd",
  "taxId": "GB 123 4567 89",
  "status": "active"
}
```

```json
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "company": {
      "id": "6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e",
      "storeId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
      "name": "Beacon Hardware Ltd",
      "externalId": "CUST-004821",
      "taxId": "GB 123 4567 89",
      "status": "active",
      "mainContactId": null,
      "createdAt": "2026-06-02T09:14:03.000Z",
      "updatedAt": "2026-08-27T11:02:47.000Z"
    },
    "created": false
  },
  "timestamp": "2026-08-27T11:02:47.112Z"
}
```

Two things worth knowing:

- **The path `externalId` always wins.** If your request body also carries an `externalId` field, it is ignored and overwritten with the value in the URL — you cannot accidentally re-key a company by sending a stale body.
- **`created` tells you which branch ran.** `true` on the first call for a given `externalId`, `false` on every call after. Log it, or use it to decide whether to also provision a default location and a main contact in the same sync pass.

This route needs both `company:create` and `company:update` — it may take either branch, so the key needs both scopes even though a single call only ever exercises one.

## Bulk onboarding with the dashboard CSV import

Merchants often seed their company list with the dashboard's company CSV import before an API sync takes over, and re-run it whenever someone hands them a spreadsheet. Two of its rules matter to an integration that runs alongside it:

- **Matching is by External ID first, case-insensitively.** A row whose `Company External ID` matches an existing company updates that company. A row whose External ID matches nothing falls back to a case-insensitive match on `Company Name`, and adopts the name-matched company only if that company has no External ID of its own yet; the company then takes on the row's External ID. That is the export, fill-in-ERP-ids, reimport flow. A name-matched company that already carries a *different* External ID is treated as a genuinely distinct company, so the row creates a new one rather than re-keying it.
- **A blank cell means "keep the existing value".** The import is tri-state: a field is written only when its cell actually carries a value, so a reimport can update the columns the spreadsheet knows about without wiping the ones it does not. The flip side is that the import can never *clear* a field. To blank a company's `taxId` or `note`, or to overwrite fields wholesale, use the API: `PATCH /v1/companies/{id}` with an explicit `null` clears the field.

Import-driven changes flow through the same write path as the dashboard and the API, so they emit the same `company.created` / `company.updated` events, and the [webhook subscriptions below](#two-way-sync-with-webhooks) see a CSV import the same way they see any other write.

## Provisioning buyers silently, then activating them in bulk

Migrating a company's buyer list from an ERP usually means creating dozens of user records before a single one of them should hear about it — you want the accounts to exist so the next sync step (assigning permissions, linking to locations) has somewhere to point, not to blast forty activation emails on day one.

`POST /v1/companies/{companyId}/users` defaults `sendActivationEmail` to `false` for exactly this reason. Create every buyer silently during the bulk import:

```http
POST /v1/companies/6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e/users HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "email": "j.armitage@beaconhardware.example",
  "firstName": "Jane",
  "lastName": "Armitage",
  "role": "buyer",
  "sendActivationEmail": false,
  "mainContact": true
}
```

The user lands **pending-activation**: the account exists, has no usable password, and cannot sign in yet. `role` accepts one of the built-in presets (`admin`, `buyer`, `viewer`) so an ERP that doesn't model Orbit's eleven individual permission flags can still assign something sensible; send an explicit `permissions` object instead if you need finer control (an explicit `permissions` always wins over `role`). `mainContact: true` also makes this user the company's main contact once created.

Once the import is complete and you're ready for buyers to actually sign in, trigger activation per user (or loop it across a company, or every company, once you're confident the data is right):

```http
POST /v1/companies/6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e/users/9c4e2b1a-7f3d-4c8e-b1a2-3d5f7e9c1b0a/send-activation HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

```json
{
  "statusCode": 201,
  "message": "Created",
  "data": { "sent": true },
  "timestamp": "2026-08-27T11:05:12.884Z"
}
```

This is also the right call to re-send a lost activation email later — it always rotates and re-sends, it is not one-shot. It returns `400 Bad Request` if the target contact has no linked login user, which cannot happen for a user your own `POST .../users` call created, but can happen if you point it at a contact id from elsewhere.

## Catching up efficiently with `updatedAtMin`

Full-table polling of every company or invoice on every sync run does not scale once a store has thousands of companies. `GET /v1/companies` and `GET /v1/b2b-invoices` both accept `updatedAtMin` (and `updatedAtMax`) so you can ask for only what changed since your last successful run:

```http
GET /v1/companies?updatedAtMin=2026-08-26T00:00:00.000Z&limit=100 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

```json
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "items": [
      {
        "id": "6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e",
        "name": "Beacon Hardware Ltd",
        "externalId": "CUST-004821",
        "status": "active",
        "updatedAt": "2026-08-27T11:02:47.000Z"
      }
    ],
    "meta": { "page": 1, "limit": 100, "total": 1, "totalPages": 1 }
  },
  "timestamp": "2026-08-27T11:06:30.501Z"
}
```

The window pattern:

1. Store the timestamp of your **last successful run**, not the time you started it — if a run fails partway, retry the same window rather than advancing past unprocessed changes.
2. Page through `items` with `page`/`limit` until `meta.page >= meta.totalPages`.
3. Set your next run's `updatedAtMin` to the timestamp you just used as the *end* of this run (typically "now, minus a small safety margin" — a few seconds — to cover any row whose write committed a moment after your query started but carries an earlier logical timestamp).
4. `GET /v1/b2b-invoices` supports the identical `updatedAtMin`/`updatedAtMax` pair for invoice status sync (a payment recorded in the dashboard flips `status` and bumps `updatedAt`, and shows up on your next poll even without a webhook).

Polling on a schedule needs nothing inbound — it works from behind a corporate firewall an ERP typically lives behind, with no public endpoint to expose. See [Background jobs](/guides/background-jobs) for keeping a credential alive and running syncs unattended.

## Two-way sync with webhooks

Polling on an interval means changes made in Orbit (a buyer added through the storefront company portal, an invoice marked paid in the dashboard) take up to a full polling interval to reach your ERP. [Webhooks](/guides/webhooks) close that gap: subscribe once, and Orbit pushes the change to you within seconds of it happening.

| Topic | Fires on | Required scope |
|---|---|---|
| `company.created` / `.updated` / `.deleted` | A company is created, edited, or soft-deleted (dashboard, storefront self-service, or your own API calls). | `company:read` |
| `company_user.created` / `.updated` / `.removed` | A buyer is added, has their role/permissions changed, or is removed — including invitations that convert into a company user. | `company:read` |
| `company_location.created` / `.updated` / `.deleted` | A location is added, edited, or removed — including edits a buyer makes through the storefront company portal. | `company:read` |
| `b2b_catalog.created` / `.updated` / `.deleted` | A catalogue is created, edited, or deleted. Every change inside a catalogue counts as an edit: price overrides, group pricing rules, and the visible-product set on a restricted catalogue all fire `.updated`, so one subscription covers any change to a catalogue's effective pricing. | `b2b-catalog:read` |
| `payment_term.created` / `.updated` / `.deleted` | A payment term is created, edited, or deleted. | `payment-term:read` |
| `b2b_invoice.created` / `.paid` / `.voided` | A net-terms invoice is generated for an order, its balance is paid off (in full or via a manual dashboard payment), or it is voided. | `b2b-invoice:read` |

Assignment changes arrive on the location, not the resource. `PATCH .../locations/{locationId}/catalog` and `PATCH .../locations/{locationId}/payment-term` mutate the location row, so they fire `company_location.updated` rather than a `b2b_catalog.*` or `payment_term.*` topic. Deleting a catalogue or payment term that locations still point at fires both kinds: the `.deleted` topic for the resource itself, plus one `company_location.updated` for every location the assignment was cleared from.

Subscribe the same way as any other topic — `webhook:create` plus the topic's own scope:

```http
POST /v1/webhooks HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "topic": "b2b_invoice.paid",
  "webhookUrl": "https://your-erp-connector.example.com/webhooks/orbit"
}
```

### The payload is a pointer, not a snapshot

Every B2B topic's `data` carries ids, not the full resource — the same shape as `cart.updated`:

```json
{
  "id": "0f3c8b2a-1d4e-4a6b-9c2f-7e5a1b2c3d4e",
  "topic": "b2b_invoice.paid",
  "created_at": "2026-08-27T11:09:02.000Z",
  "store_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "data": {
    "invoiceId": "3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e",
    "orderId": "b1a2d5f7-e9c1-4b0a-8c4e-9c1b0a3d5f7e",
    "companyId": "6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e",
    "storeId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
  }
}
```

Treat the delivery as "go look up `invoiceId`" — fetch `GET /v1/b2b-invoices/{invoiceId}` (or `GET /v1/companies/{companyId}`, `GET /v1/companies/{companyId}/users`, `GET /v1/companies/{companyId}/locations/{locationId}`, `GET /v1/catalogs/{catalogId}`, `GET /v1/payment-terms/{paymentTermId}`, depending on the topic) for the current, authoritative state. `b2b_catalog.*` deliveries carry `{ catalogId, storeId }` in `data`, and `payment_term.*` deliveries carry `{ paymentTermId, storeId }`. This keeps the payload small and means you're never reconciling a stale snapshot the delivery happened to carry — by the time your handler runs, the read reflects everything that has happened since, including a second change that landed before you processed the first.

Combine push and pull for a resilient sync: let webhooks drive near-real-time updates, and run the `updatedAtMin` poll from the previous section on a longer interval (hourly, say) as a safety net for any delivery that failed all its retries.

## `FEATURE_FROZEN`: reads keep working, writes do not

Every B2B route depends on the store's plan carrying the B2B feature. If a merchant downgrades off a plan that includes it, your integration does not go dark:

- **Reads keep working.** `GET` requests across `/v1/companies`, `/v1/catalogs`, `/v1/payment-terms`, and `/v1/b2b-invoices` all continue to succeed, so an ERP that only needs to pull data (invoice status, for instance) is unaffected by a downgrade.
- **Writes are refused.** Any `POST`, `PATCH`, `PUT`, or `DELETE` on a B2B route returns `403 Forbidden`:

```json
{
  "statusCode": 403,
  "timestamp": "2026-08-27T11:11:40.203Z",
  "path": "/v1/companies",
  "method": "POST",
  "message": "B2B is not enabled on this store. Writes require the \"b2b\" feature on the store plan.",
  "code": "FEATURE_FROZEN",
  "feature": "b2b"
}
```

Check for `code === "FEATURE_FROZEN"` in your error handling and surface it distinctly from a validation or auth failure — it means "the merchant needs to change their plan," not "your request was wrong" or "your key needs a different scope." A sync job that treats it as a transient error and retries indefinitely will keep failing until the plan changes; log it once per company/invoice and move on to the next item in the batch instead of blocking the run.

## Next steps

- The exact scope strings and every endpoint they grant: [Scope Catalog](/scope-catalog#companies-company).
- Push events for every other resource, and how signing and retries work: [Webhooks](/guides/webhooks).
- Creating and managing the credential itself: [Store API keys](/guides/api-keys).
- Running an unattended sync job: [Background jobs](/guides/background-jobs).
