Orders, approvals and pay on account
When a company user places an order, the order carries the company's details: the purchase order number, the branch it is for, delivery instructions, and a cost centre and lead time on each line. A company can pay on account instead of by card. An order can also be held until someone in the company approves it. This page covers what those orders look like on the API, how to find them and how to act on them.
What a company order carries
GET /v1/orders/{id} (order:read) and GET /v1/orders (order:list) return these fields on every order. They are null on orders that do not use them.
| Field | Type | Meaning |
|---|---|---|
companyId | uuid or null | The company the order was placed under, recorded when it was placed. |
poNumber | string, up to 64 characters, or null | The buyer's purchase order number. |
companyLocationId | uuid or null | The branch (company location) the order is for. |
deliveryInstructions | string or null | What the buyer typed for this delivery. The branch's standing instructions are on the location; emails and documents print the branch's first, then the order's. |
paymentStatus | string | payment_due on an order placed on account (or created unpaid): the money is owed, and the order is never cancelled for being unpaid. An on-account order held for approval is payment_due too, with amountDue "0.00" until it is approved. |
amountDue | decimal string | What is still owed, such as "292.96". Parse it before doing sums with it. |
exchangeRate | decimal string or null | The rate from the store's primary currency to the order's currency, recorded when the order was placed: "1.00000000" for an order in the primary currency. |
balanceDueAt | date-time or null | When an order placed on account or on payment terms falls due: null while it is held for approval, and on a fulfillment term until the order is fully fulfilled, when it is set to that moment. |
metadata.payment_flow | string | on_account on orders placed on account, and on on-account orders held for approval. |
b2bInvoice | object or null | The invoice that bills the order: id, invoiceNumber, status, paidAt. |
Each entry of items also carries:
| Field | Type | Meaning |
|---|---|---|
costCentreId | uuid or null | The cost centre the line is charged to. |
costCentreCode, costCentreName | string or null | The code and name as they were at placement, kept if the cost centre is renamed or archived later. |
leadTimeDays | integer or null | Days until the line could ship when the order was placed: 0 in stock, null unknown. |
expectedDate | date or null | When the line is expected to ship. |
Finding company orders
GET /v1/orders (order:list, at most 100 per page) takes these filters, alongside status, paymentStatus, fulfillmentStatus, customerId, dateFrom, dateTo, updatedFrom, externalId and includeArchived:
| Parameter | Orders it keeps |
|---|---|
companyId | placed under this company |
poNumber | with exactly this PO number, ignoring case |
costCentreId | with at least one line charged to this cost centre |
approvalState | awaiting, approved or declined |
GET /v1/orders?companyId=6f2a1c3e-9d4b-4a7f-8e2c-1b3f5a7c9d0e&paymentStatus=payment_due&includeArchived=true&limit=100 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCredit in use
The API keeps no running balance for a company. The credit it is using is counted in the store's primary currency, as creditLimit is: for each of its orders whose paymentStatus is payment_due, archived orders included, leaving out any whose status is canceled, take amountDue divided by exchangeRate (an order in the primary currency counts at face value, and a missing, zero or negative rate counts as 1), and add them up. The query above lists those orders: GET /v1/orders leaves archived orders out unless you send includeArchived=true, so keep that parameter, and read every page. amountDue and exchangeRate are decimal strings, so parse them before doing sums. The company's creditLimit is on the company.
Pay on account
With pay on account, the buyer places the order without paying and the store bills the company from its own accounts system.
At storefront checkout, a user may pay on account when all of these hold:
- pay on account is on for the company (the store setting, or
payOnAccountEnabledin itsb2bSettings); - the company's
statusisactiveandonStopisfalse; - the user's
statusisactiveand theirpayOnAccountis notfalse; - the order's branch has an active payment term, or failing that the location Orbit falls back to does: the company's default location, or, with per-user limits on, the user's default delivery location when they may use it, else the default among their allowed locations, else the company's default location. Any active term will do, whatever its type;
- the order fits in the credit left, or the company has no
creditLimit.
At checkout, an order that would go over the limit follows the company's overCreditLimit. With approval, while order approval is on for the company, the order is held for approval, with approvalTrigger: "credit_limit" when no earlier trigger matched, and placed on account only once it is approved. Otherwise pay on account is refused with errorCode: "B2B_CREDIT_LIMIT_EXCEEDED" and the buyer pays another way. Credit is counted as a checkout or an approval places the order, under a lock on the company, so two buyers placing orders at once cannot both spend the same credit. An order created through POST /v1/orders is never checked against the limit, but once created unpaid it counts towards the credit in use like any other.
An order placed on account:
- is
processing, withpaymentStatus: "payment_due"; - has
amountDueequal to its total, andbalanceDueAtset from its payment term: the invoice date for areceiptterm, the invoice date plusnetDaysfor anetorexternalterm, the term'sdueDatefor afixedterm. The invoice date is the calendar day the order was placed in the store's time zone, sobalanceDueAtis the due day at 00:00 UTC. On afulfillmentterm it starts asnull: once the order is fully fulfilled, its invoice takes the store's calendar day of that moment as its due date, andbalanceDueAttakes the moment itself; - carries
metadata.payment_flow: "on_account"; - has a B2B invoice whose
termTypeisexternaland whoseinvoiceNumberisINV-followed by the order number.
No card is charged and no payment link is sent. The topics order.created, b2b_invoice.created, order.confirmed and order.placed_on_account fire. When the merchant records the payment against the invoice, under Invoices in the dashboard, the invoice and the order are settled together: order.paid and b2b_invoice.paid fire and paymentStatus becomes paid. Marking the order itself paid settles its invoice too: when PATCH /v1/orders/{id} sets paymentStatus: "paid", or the merchant marks the order paid on the dashboard order page or with the bulk action, order.paid fires and the order's open or past-due invoice becomes paid, with amountPaid equal to amountDue and paidAt set. b2b_invoice.paid follows, with its usual payload, and no second order.paid. An invoice already settled is left as it is.
Payment terms for accounts the store invoices itself
POST /v1/payment-terms (payment-term:create) with a name, type: "external" and netDays creates the term the dashboard calls "Invoiced externally". Orbit records the invoice and its due date, but never charges a card or emails a payment link for it. Pay on account does not need this type: an order placed on account takes any active term, and its invoice is always typed external. Assign it to a location with PATCH /v1/companies/{companyId}/locations/{locationId}/payment-term.
Creating company orders through the API
POST /v1/orders (order:create) accepts poNumber, companyLocationId and deliveryInstructions, and on each item costCentreCode.
It is a trusted create: the order is never held for approval, and no spending limit, credit limit, pay on account rule or PO number rule applies to it, so your system is trusted with the PO number and instructions as sent. Its paymentStatus defaults to payment_due, so unless you send another, the order counts towards the company's credit in use. It gets no B2B invoice and no balanceDueAt.
The order's company comes only from customerId. Send the customerId of the company user the order is for: every company user object carries it. An email alone never links an order to a company.
currencyId takes the currency's id or its ISO 4217 code, such as GBP, when the store has that currency enabled; any other code answers 400 with code: "ORDER_CURRENCY_NOT_ENABLED".
companyLocationId must be a location of that company, and each costCentreCode must name one of its cost centres (matched ignoring case); otherwise the request answers 400 with B2B_LOCATION_NOT_IN_COMPANY or B2B_COST_CENTRE_NOT_FOUND. An order without a company (no customerId, or a customer who is not a company user) has no locations or cost centres, so a branch or a cost centre on it is refused the same way.
POST /v1/orders HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"email": "jane.armitage@beaconhardware.example",
"customerId": "4e8a2c6f-3b1d-4f7a-9c2e-5d6b7a8c9e0f",
"currencyId": "GBP",
"shippingAmount": 0,
"poNumber": "PO-48213",
"companyLocationId": "2b7d4e6f-1a3c-4b5d-8e9f-0a1b2c3d4e5f",
"deliveryInstructions": "Goods in at the rear, 8am to 4pm",
"items": [
{ "sku": "HB-2201", "name": "Hinge, brass", "quantity": 40, "price": 2.15, "costCentreCode": "MAINT" }
]
}Approvals
When order approval is on for the company (the store setting, or orderApprovalEnabled in its b2bSettings), an order placed at storefront checkout is held instead of being paid or placed. Orders created through the API or in the dashboard are never held. A card, manual payment, payment terms or on-account checkout can be held; a deposit, instalment or gift card checkout that matches a trigger is refused with errorCode: "B2B_APPROVAL_PAYMENT_UNSUPPORTED". The first trigger that matches decides, and approvalTrigger records which:
spending_limit: the order is over the buyer'sspendingLimitand theiroverSpendingLimit(else the company's) isapproval.order_value: the total is over the company'sapprovalThreshold.non_contract:approvalNonContractis on, the company has a catalogue, and a line is priced from the store's standard price.credit_limit: the order is on account, it would go over thecreditLimit, andoverCreditLimitisapproval.
When approval is off, only the spending limit applies, and checkout refuses an order over it with errorCode: "B2B_SPENDING_LIMIT_EXCEEDED".
Every limit is in the store's primary currency: the company's creditLimit and approvalThreshold, and a user's spendingLimit and approvalLimit. An order is compared with them at its total divided by its exchangeRate, to the penny (a missing, zero or negative rate counts as 1), and at checkout the basket is converted at the rate the order will record.
A held order:
- is
pending, withapprovalState: "awaiting"andapprovalRequestedAtset; - has its stock reserved, nothing charged, and is never cancelled for being unpaid;
- has
paymentStatuspayment_duefor an on-account order andpendingfor every other flow, andamountDue"0.00"either way: an on-account order gets its invoice andbalanceDueAtonly when it is approved; - has no ship-by date and starts no CRM automations yet: both start when it is approved;
- names who was asked: its
authoriserContactIdandsecondAuthoriserContactIdare the pair the request or the latest reminder reached, and both arenullwhen nobody at the company was asked, a reminder to the store team included.
Do not send an order with approvalState: "awaiting" to fulfilment or to your accounts system: wait for order.approved. PATCH /v1/orders/{id} refuses a status or paymentStatus that would change a held order with 409 and errorCode: "ORDER_AWAITING_APPROVAL", and writes nothing. Sending the values the order already has is accepted, so a re-sync is not refused, and notes, metadata and archived still apply.
While an order is held, Orbit keeps what its decision needs in metadata: approval_payment_flow, approval_staff_only and approval_payment_term_id. Like payment_flow, these keys belong to the platform: PATCH /v1/orders/{id} ignores them and POST /v1/orders drops them.
Who decides
Any active contact of the order's company who has canApproveOrders and whose own approvalLimit covers the order total may decide it. There is no exemption for the second authoriser or for company managers: a manager without canApproveOrders cannot decide, and an approver whose limit is below the total cannot either. An approvalLimit of null means no limit.
Routing decides only who is emailed, under the same rule. Orbit asks the company's authoriser (authoriserContactId) when they can decide the order and copies in the secondAuthoriserContactId when they can decide it too. When the authoriser cannot decide it, the second authoriser is asked instead. When neither can, or none is named, Orbit asks up to two of the company's other users who can decide it: the one whose approvalLimit is the smallest that covers the total first, then users with no limit, then the longest-standing user, never the buyer who placed the order. The order's authoriserContactId and secondAuthoriserContactId then name them. When nobody but the buyer can decide it, no company user is emailed, and when nobody at the company can, only store staff can.
Store staff can always decide, in the dashboard or through the routes below. Once the company's approvalReminderHours have passed (else the store's, 24 by default), checked every hour, every held order gets one reminder. Who receives it is worked out again at that moment: the authoriser when they can still decide the order (with the second authoriser copied when they can too); else the first who can of the order's second authoriser and the company's current authoriser and second authoriser; else up to two of the company's other users who can decide it, chosen as above; else the store team at the store's new-order email address. So an order whose request reached nobody is reminded to whoever can decide it by then. A reminder that fails to send is tried again the next hour, whether it went to company users or to the store team; one that staff send by hand from the dashboard is not.
Approving and declining through the API
POST /v1/orders/{id}/approve and POST /v1/orders/{id}/decline (order:update) take an optional reason, up to 500 characters (a longer one answers a plain validation 400), which is stored on the order and sent to the buyer when declining.
- Approving sets
approvalState: "approved". A card order then waits for the buyer to pay with the link Orbit emails them, which works for 30 days from the decision. An order paid by manual payment (a bank transfer and the like) waits for its payment, like any manual order, withpaymentStatus: "awaiting_payment". An on-account order is placed on account at once. An order on payment terms gets its invoice, due by its term, and stayspendinguntil the invoice is paid; when the buyer can no longer pay on payment terms, approving it, by any route, answers403witherrorCode: "B2B_PAYMENT_TERMS_UNAVAILABLE"and the message "This buyer can no longer pay on payment terms, so the order cannot be placed on terms.", and the order stays awaiting approval. Storefront checkout refuses an order on payment terms from such a buyer with the same code. - Approving an on-account order checks it again at that moment, under a lock on the company. The call answers
403witherrorCode: "B2B_PAY_ON_ACCOUNT_UNAVAILABLE"when the buyer is no longer a user of the company (or belongs to another one), the company is suspended or on stop, the user is switched off, pay on account is off for the store, the company or the user, or no active payment term can take the order. It answers403witherrorCode: "B2B_CREDIT_LIMIT_EXCEEDED"when the credit the company's other orders already use, plus this order, is over itscreditLimit, unless order approval is on for the company and itsoverCreditLimitisapproval. Either way the order stays awaiting approval, and the approval links already emailed still work. - An approved on-account order is placed on the payment term checkout validated. Only when that term is gone or inactive does it fall back to the branch's term, then the company's default location's.
- Declining sets
approvalState: "declined", cancels the order with your reason, releases its stock and emails the buyer. - Both answer the order as
GET /v1/orders/{id}returns it. An order that is not awaiting approval answers409witherrorCode: "ORDER_NOT_AWAITING_APPROVAL".
POST /v1/orders/3f9c1b7a-2d4e-4c6f-8a1b-9e2d3c4b5a6f/decline HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer oc_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{ "reason": "Over this quarter's budget" }The decision is recorded on the order:
approvalDecidedAt;approvalDecidedVia:account,email,dashboardorapi;approvalReason;approvalDecidedByContactIdwhen a company user decided;approvalDecidedByUserIdwhen store staff did.
What fires when
A held order is not announced as an order: there is no order.created until it is approved. Subscribers hear order.approval_requested, and checkout.completed too, because the buyer did finish checking out, so read the order's approvalState before acting on checkout.completed: a held order reads awaiting. Nothing else fires until the order is decided. A held order that store staff cancel instead fires no further topic: it reads status: "canceled" with approvalState still awaiting, and approving or declining it answers 409 with ORDER_NOT_AWAITING_APPROVAL.
| Moment | Topics |
|---|---|
| Placed on account (never held) | order.created, checkout.completed, b2b_invoice.created, order.confirmed, order.placed_on_account |
| Placed on payment terms (never held) | order.created, b2b_invoice.created, checkout.completed |
| Held for approval | order.approval_requested, checkout.completed (the order reads approvalState: "awaiting") |
| Approved, card order | order.created, order.approved; the payment topics follow when the buyer pays |
| Approved, manual payment | payment.awaiting_manual, then order.created and order.approved; order.paid follows when the store records the payment |
| Approved, on account | b2b_invoice.created, order.confirmed, order.placed_on_account, then order.created and order.approved |
| Approved, payment terms | b2b_invoice.created, then order.created and order.approved; order.paid and b2b_invoice.paid follow when the invoice is paid or the order is marked paid |
| Declined | order.declined |
| Invoice paid: recorded under Invoices in the dashboard, or a payment-terms invoice paid through Stripe | order.paid (when the order was still unpaid), b2b_invoice.paid |
Order marked paid: PATCH /v1/orders/{id}, the dashboard order page or the bulk action | order.paid, then b2b_invoice.paid when the order had an open or past-due invoice |
| Order cancelled | order.canceled, or order.updated when PATCH /v1/orders/{id} sets status: "canceled"; then b2b_invoice.voided, with reason: "order_canceled", for each open or past-due invoice it had |
A payment-terms invoice that Stripe has issued has a hosted invoice the buyer can pay (its hostedInvoiceUrl). When Orbit settles the invoice itself (a payment recorded under Invoices, or the order marked paid) or voids it (in the dashboard, or by cancelling the order), it closes that hosted invoice, so the buyer can no longer pay it: Stripe records it as paid outside Stripe, or voids it, or deletes it while it is still a draft. A hosted invoice Stripe already shows as paid or void is left as it is. If Orbit voids an invoice the buyer has already paid in Stripe, nothing is refunded automatically: refund the payment from Stripe if it should not stand. On-account invoices are never sent to Stripe, so none of this applies to them.
An approved manual order's payment.awaiting_manual carries notifyCustomer: false: the buyer gets the approval email rather than the payment instructions email.
A decision's topics go out only once the decision is saved: the placement's first, then order.created and order.approved. Deliveries are not guaranteed in that order, so order.placed_on_account may arrive before or after order.created and order.approved. Read the order's approvalState and paymentStatus from /v1 rather than relying on the sequence.
Next steps
- A company's orders and balance as a document: Company statements.
- The company and user fields an order draws on: Companies, buyers and locations.
- Every topic an order fires, with its payload: Webhooks.