Skip to content

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.

In this section

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 familyTypical grant
company:*Full CRUD: most ERP syncs create, read, and update companies, users, invitations, locations, and cost centres.
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:readInvoices are read-only on the public API regardless: there is no write scope to grant.
order:list + order:read (and order:update)Pull company orders with their PO numbers, branches and cost centres; order:update also approves or declines held orders.
order:create + order:listIf your ERP pushes the orders it takes itself (phone, EDI, trade counter) into Orbit. See Importing orders from your ERP.
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.

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"
}

Three 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, so you cannot accidentally re-key a company by sending a stale body. The lookup ignores case and surrounding spaces, and a matched company takes your spelling of the id from the path.
  • 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.
  • An external id belongs to one company, ignoring case. Giving a company an externalId another company holds in any casing answers 409 with errorCode: "B2B_COMPANY_EXTERNAL_ID_TAKEN", and so does an upsert whose id two older companies hold in different casings, since it cannot tell which to update.

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.

Retrying writes safely

Send an Idempotency-Key header on every POST, PUT, PATCH and DELETE your sync makes. The key is any unique string up to 255 characters, and each credential has its own keys.

  • A request is the same when its method, its path with the query string, and its JSON body are the same. If the same key arrives again within 24 hours of the first request with the same request, and that first request succeeded, Orbit returns the original answer unchanged and the write is not repeated; the replay carries an Idempotent-Replay: true header.
  • Only a success is kept. A request that failed, with a validation 400 or anything else, leaves the key free, so a retry with it runs again.
  • Reusing a key for a different request answers 409 with code: "IDEMPOTENCY_KEY_REUSED", and a retry that arrives while the first request is still running answers 409 with code: "IDEMPOTENCY_IN_PROGRESS". A key over 255 characters answers 409 with code: "IDEMPOTENCY_KEY_TOO_LONG".

Mint a new key for each run of your sync, and reuse it only to retry that run's request. A key built from the date alone, such as costcentres-CUST-004821-2026-10-08, breaks a second run on the same day: with a changed body it answers 409 and writes nothing, and with the same body it replays the first run's answer.

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, which takes the row's spelling of the id. 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". A field is written only when its cell carries a value, so a reimport can update the columns the spreadsheet knows about without wiping the ones it does not. To clear a value, type none in a column that takes it, such as Credit Limit, Status Note or Pricing Catalogue; the tables in CSV imports and exports say which do, and a blank Tags cell, or a blank cell in a cf:<name> column the file carries, clears too. A column with no none, such as Tax ID or Note, is cleared through the API instead: PATCH /v1/companies/{id} with null for taxId or note.

The company file also carries each company's credit, approval and checkout rules and each user's limits, and branches and cost centres have files of their own. CSV imports and exports lists every column.

Import-driven changes to companies and their users flow through the same write path as the dashboard and the API, so they emit the same company.created, company.updated and company_user.* events, and the webhook subscriptions below see them as they see any other write. The company file's Pricing Catalogue, Payment Terms and Tax Exempt cells are the exception: they change the company's default location without firing company_location.updated. They do move the company's updatedAt, so the updatedAtMin poll still finds the company.

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 send 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.

Each store has its own shop accounts. The user's login is an account on this store's shop only: it is separate from any account the same email has on another store or on an Orbit dashboard, and creating it never touches those. If this store already has an account with the email, POST .../users answers 409 Conflict with errorCode: "B2B_EMAIL_HAS_LOGIN". Send that person a company invitation instead (POST /v1/companies/{companyId}/invitations); they accept it by signing in with their account.

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: each call issues a fresh link and sends it again, until the user has set their password. After that it answers 409 Conflict with errorCode: "CUSTOMER_ALREADY_ACTIVE", and the user signs in or uses Forgot password on the shop. 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. It returns 409 Conflict with errorCode: "CUSTOMER_EMAIL_OTHER_REALM" if the contact's record is still linked to a login that is not this store's own account.

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, and so does GET /v1/company-users for the users of every company:

HTTP
GET /v1/companies?updatedAtMin=2026-08-26T00:00:00.000Z&includeDeleted=true&sortBy=updatedAt&sortDir=ASC&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. Sort by sortBy=updatedAt&sortDir=ASC and page through items with page/limit until meta.page >= meta.totalPages. Without the sort, companies come oldest created first and invoices newest issued first.
  3. Ask for what was removed too: includeDeleted=true on GET /v1/companies adds deleted companies with their deletedAt, and includeRemoved=true on GET /v1/company-users adds removed users with their removedAt. Invoices are never deleted: a voided one keeps its row, with status: "void".
  4. 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" of a few seconds, to cover any row whose write committed a moment after your query started but carries an earlier logical timestamp).
  5. 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 the 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.

TopicFires onRequired scope
company.created / .updated / .deletedA company is created, edited, or soft-deleted (dashboard, storefront self-service, or your own API calls).company:read
company_user.created / .updated / .removedA buyer is added, has their role or permissions changed, or is removed, including invitations that convert into a company user. Deleting a company removes its users without a company_user.removed for each: only company.deleted fires.company:read
company_location.created / .updated / .deletedA location is added, edited or removed, including edits a buyer makes through the storefront company portal.company:read
b2b_catalog.created / .updated / .deletedA 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 / .deletedA payment term is created, edited, or deleted.payment-term:read
b2b_invoice.created / .paid / .voidedAn invoice is generated for an order placed on payment terms or on account; it is paid, whether its payment is recorded under Invoices in the dashboard, its Stripe invoice is paid, or its order is marked paid (through the API, on the dashboard order page or with the bulk action); or it is voided.b2b-invoice:read
order.approval_requested / .approved / .declinedAn order is held for approval, or is approved or declined by its authoriser, store staff or your own API call.order:read
order.placed_on_accountAn order is placed on account: no card payment, an invoice the store bills from its own accounts system.order:read
cost_centre.created / .updated / .archivedA cost centre is added, changed or archived, by the dashboard, the storefront company account, an import or your API calls.company: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, with 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 a pointer to 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 }. order.approval_requested, order.approved and order.declined deliveries carry { orderId, companyId, storeId }, order.placed_on_account adds invoiceId, and cost_centre.* deliveries carry { costCentreId, companyId, storeId }: read GET /v1/orders/{orderId} or GET /v1/companies/{companyId}/cost-centres/{costCentreId}. Read the single cost centre rather than the list: the list leaves archived cost centres out unless you send ?status=all. This keeps the payload small and means you are 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.

Importing orders from your ERP

Orders taken outside the storefront (by phone, by EDI, at a trade counter) usually start life in the ERP. POST /v1/orders brings them into Orbit so they sit beside the website's orders. Each order keeps the ERP's order number, and each line links to the product it sold, without your ERP ever needing an Orbit id.

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

{
  "email": "j.armitage@beaconhardware.example",
  "currencyId": "GBP",
  "orderNumber": "SO-10442",
  "externalId": "SO-10442",
  "source": "sage",
  "paymentStatus": "payment_due",
  "reserveStock": false,
  "shippingAmount": 0,
  "items": [
    { "name": "A4 copier paper, box of 5", "sku": "DYN-A4-500", "quantity": 2, "price": 18.75 },
    { "name": "Operator chair", "sku": "DYN-CHAIR", "quantity": 1, "price": 129.0 },
    { "name": "Carriage", "sku": "CARRIAGE", "quantity": 1, "price": 6.95 }
  ]
}
JSON
{
  "statusCode": 201,
  "message": "Created",
  "data": {
    "id": "b1a2d5f7-e9c1-4b0a-8c4e-9c1b0a3d5f7e",
    "orderNumber": "SO-10442",
    "paymentStatus": "payment_due",
    "unmatchedSkus": [
      { "sku": "DYN-CHAIR", "reason": "variant_required" },
      { "sku": "CARRIAGE", "reason": "not_found" }
    ]
  },
  "timestamp": "2026-10-21T14:02:11.380Z"
}

The response above is shortened: data is the full order, plus unmatchedSkus.

  • Lines link by SKU. A line that sends neither productId nor productVariantId is matched to the catalogue by its sku. Letter case and spaces around the code are ignored, and product and variant SKUs both match. When several listings carry the same SKU, the line links to the live one; when more than one is live, to the oldest. A linked line is saved under the catalogue's product name, like any line that names a product.
  • unmatchedSkus lists what did not link. A SKU that no listing carries (not_found), or one that only matches a product with several variants, so the option sold is unknown (variant_required), still imports: the line keeps its name, SKU and price but links to no product. Each such SKU is listed once, in line order, and the list is [] when every line linked. To link a variant_required line, send the variant's own SKU or its productVariantId.
  • Your prices stand. Every line keeps the price, discount and tax you send, even when the catalogue price is different (an older price, a contract price). Linking a line never reprices it.
  • orderNumber keeps the ERP's number. The order carries it exactly, instead of the store's next number, and the store's own numbering carries on untouched, so website orders keep their sequence. It may use letters, digits, ., _ and -, up to 50 characters. A number can be used once per store: a repeat is refused with 409 and code: "ORDER_NUMBER_EXISTS", with the order that already has it in orderId. The number cannot be changed after the order is created. If your ERP numbers are plain digits (up to 9 digits) and the store numbers its own orders sequentially with the same prefix and suffix (for example 10442 on a store with no prefix), the store's next website number skips past the highest such number the first time they would meet. Numbers with a prefix the store doesn't use, such as SO-10442, never affect website numbering, and a store on the date-based format is never affected.
  • Send the same value as externalId. It makes the import safe to retry: a second create with the same externalId is refused with 409 and code: "ORDER_EXTERNAL_ID_EXISTS", naming the existing order, so a sync that timed out can simply send the order again. GET /v1/orders?externalId=SO-10442 finds it later.
  • Stock. A linked line holds stock like a website order, and a line without enough stock refuses the whole order (400, code: "ORDER_OUT_OF_STOCK"). When the ERP has already taken the stock, or the order is historical, send reserveStock: false. For historical orders, triggerAutomations: false also keeps the import from running order automations such as order.paid.

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