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 and the 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 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 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.
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.
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"
}
{
"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
externalIdalways wins. If your request body also carries anexternalIdfield, it is ignored and overwritten with the value in the URL — you cannot accidentally re-key a company by sending a stale body. createdtells you which branch ran.trueon the first call for a givenexternalId,falseon 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 IDmatches an existing company updates that company. A row whose External ID matches nothing falls back to a case-insensitive match onCompany 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
taxIdornote, or to overwrite fields wholesale, use the API:PATCH /v1/companies/{id}with an explicitnullclears 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 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:
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):
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
{
"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:
GET /v1/companies?updatedAtMin=2026-08-26T00:00:00.000Z&limit=100 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
{
"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:
- 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.
- Page through
itemswithpage/limituntilmeta.page >= meta.totalPages. - Set your next run's
updatedAtMinto 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). GET /v1/b2b-invoicessupports the identicalupdatedAtMin/updatedAtMaxpair for invoice status sync (a payment recorded in the dashboard flipsstatusand bumpsupdatedAt, 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 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 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:
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:
{
"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.
GETrequests across/v1/companies,/v1/catalogs,/v1/payment-terms, and/v1/b2b-invoicesall 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, orDELETEon a B2B route returns403 Forbidden:
{
"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.
- Push events for every other resource, and how signing and retries work: Webhooks.
- Creating and managing the credential itself: Store API keys.
- Running an unattended sync job: Background jobs.