Skip to content

API

B2B Pricing answers a JSON API over HTTPS, for the system that keeps the merchant's own records rather than for a browser.

This page is generated from the extension's contract declaration — the same data the routes are served from, the committed snapshot is diffed against and the release's smoke pass walks. So it cannot describe a route the version you installed does not answer, and a field that moves moves this page or fails the build.

A caller authenticates as one of OpenCart's own API users, under System > Users > API, and the API has to be switched on for the extension. That switch is under Extensions > Extensions > Modules, on this extension's own settings screen, in its API section. With it off, every route here answers 404 api_disabled, so an extension with its API off is shaped like one that never had it.

That switch is independent of the extension's own status. B2B Pricing can be switched off while its API keeps answering, and that is deliberate: a merchant who switches the extension off has stopped it doing its job in the storefront, which is precisely when the system holding their records still needs to read what is already there. Coupling the two would break a nightly sync every time the merchant closed a window, with 404 api_disabled as the only clue.

Every answer is the same envelope

A success carries data. A collection carries page beside it:

{"data": [], "page": {"limit": 50, "count": 0, "cursor": null}}

A refusal carries error, and no data at all:

{"error": {"code": "not_found", "message": "No such record.", "status": 404}}

Exactly one of the two is present, always — including for a refusal the store made before it read anything of the call.

limit defaults to 50 and is clamped to 200; page.limit is the value actually applied, so a caller who asked for more can see they were clamped rather than infer it from a short page. page.cursor is null on the last page, and it is the only reliable end-of-walk signal: a full page is not evidence of more rows and a short one is not evidence of none. There is no total.

A money value is an object carrying amount, a string at four decimal places, and the currency it is in. A day is YYYY-MM-DD; a moment is RFC 3339 with an offset, and which offset is the last section on this page.

Branch on code. The message is prose for a person reading a log and is not promised; status in the body is the status on the wire. A refusal that names the parameters it rejected carries fields, a map of parameter name to the codes that rejected it.

These codes belong to the envelope and mean the same thing in every one of our extensions that serves an API:

Code Status
api_disabled 404
tls_required 426
unauthorized 401
unknown_version 404
unknown_route 404
method_not_allowed 405
not_found 404
invalid_parameter 400
malformed_json 400
unsupported_media_type 415
payload_too_large 413
internal_error 500

v1

Four resources and twelve routes, for the system the merchant's prices are negotiated in: create tonight's list, upsert its rungs in batches of a thousand, and move one assignment onto it. That last call is the whole cutover and the only moment any buyer's price changes.

Codes of its own

Beside the envelope's, this version answers with codes for conditions in the merchant's own domain:

Code Status
list_assigned 409

price_list

A named set of prices, and the unit of everything this extension does. A list is shared across stores; who is on it is not.

index.php?route=extension/b2b_pricing/api/v1/price_list
One record &list_id=
The collection the same route without &list_id, ordered b2b_pricing_list_id DESC
Verbs GET, and POST on each transition below

Fields

In the order they are emitted in.

Field Type Notes
list_id integer
name string Merchant prose, and buyer-visible: the product page prints which list won.
status boolean A list at false is never read, whoever is assigned to it and whatever the date. It is not a second clock — the dates live on the assignment.
rows integer How many rungs the list holds.
products integer How many distinct products those rungs price.
assignments integer How many assignments point at it, live or not. Non-zero does not by itself refuse a destructive write: list_assigned is about a live one.
date_added string, RFC 3339
date_modified string, RFC 3339 Moves when anything in the list moves — a rung, an assignment or the name.

The collection

The order is the contract, not a default. It is b2b_pricing_list_id DESC, it is what the cursor is keyed on, and there is no sort parameter: a second order is a second permanent promise and a second index to keep.

Walk it with &cursor=, taking the value from the previous page's page.cursor and changing nothing else. The cursor names a row rather than a position, so a row inserted mid-walk cannot shift the page under you.

It may be narrowed by these, and by nothing else — any other parameter is 400 invalid_parameter naming itself in fields.

Parameter Type Notes
filter_status integer 1 for the lists that are switched on, 0 for the drafts.

Writes

Each is a POST to a named sub-address of the resource, with a JSON body and the record itself in data on success. A retry that arrives after the first call landed is refused rather than performed twice, and the client's move is to re-fetch the record.

price_list.create

A new list, born with no rungs and nobody on it. Step one of the cutover: nothing a buyer sees changes until an assignment is moved onto it.

Body field Type Notes
name string Required At most 64 characters. Buyer-visible: the product page prints it. Not unique — two lists may share a name, and the id is what everything else refers to.
status boolean Optional Defaults to false, which is a list that is never read whoever is assigned to it. Building next quarter's prices in the open is what the default is for.
price_list.delete

The list, its rungs and its assignments, gone. Refused while a live assignment points at it.

Beside the envelope's codes, it may answer list_assigned (409).

price_list.empty

Every rung out of the list, the list itself kept. Refused while a live assignment points at it, because emptying a live list un-prices every product for every customer on it in one call.

Beside the envelope's codes, it may answer list_assigned (409).

row

One rung of one list: what a product costs there from a given quantity upwards. It has no single fetch — a rung is identified by its list, its product and its quantity rather than by a key anybody quotes — so filter_list_id is how one list's rungs are read back.

index.php?route=extension/b2b_pricing/api/v1/row
The collection this route, ordered b2b_pricing_row_id ASC
Verbs GET, and POST on each transition below

Fields

In the order they are emitted in.

Field Type Notes
row_id integer
list_id integer
product_id integer
model string Core's own, read back off the product. Empty where the product has been deleted.
sku string Core's own, read back off the product. Empty where the product has been deleted.
quantity integer The rung: the price applies from this quantity upwards, until a higher rung on the same list takes over.
price money object Absolute, in the store's own currency. A list row is never a percentage of anything.

The collection

The order is the contract, not a default. It is b2b_pricing_row_id ASC, it is what the cursor is keyed on, and there is no sort parameter: a second order is a second permanent promise and a second index to keep.

Walk it with &cursor=, taking the value from the previous page's page.cursor and changing nothing else. The cursor names a row rather than a position, so a row inserted mid-walk cannot shift the page under you.

It may be narrowed by these, and by nothing else — any other parameter is 400 invalid_parameter naming itself in fields.

Parameter Type Notes
filter_list_id integer One list's rungs, which is what an integrator reconciling a load asks for. A list that does not exist is 404 not_found rather than an empty page.
filter_product_id integer One product's rungs. Ask it with filter_list_id for that product's ladder on one list.

Writes

Each is a POST to a named sub-address of the resource, with a JSON body and the record itself in data on success. A retry that arrives after the first call landed is refused rather than performed twice, and the client's move is to re-fetch the record.

row.upsert

A batch of rungs into one list, keyed on (list, product, quantity). The same batch posted twice leaves the same state, which is what makes a nine-call load survive a timeout. Allowed against a list somebody is on: a rung is independently valid and there is no such thing as half a price.

Body field Type Notes
list_id integer Required The list the rungs go into. 404 not_found where there is no such list.
match_on string Required One of product_id, model, sku. How each row's identifier names a product. Required rather than defaulted: a batch matched on the wrong column writes nothing and reports every line unresolved, which is a slow way to learn what the default was.
rows array of row_input Required At most 1000 characters. At most 1,000. More is 400 invalid_parameter naming rows with out_of_range. A malformed entry names itself, as rows[7].

assignment

Who is on a list, in which store, between which dates, and which list wins when two of them apply. The collection is the whole read surface — an assignment has no single fetch, because the page an integrator already walks answers every question one would — and the three writes address one by &assignment_id=.

index.php?route=extension/b2b_pricing/api/v1/assignment
The collection this route, ordered b2b_pricing_assignment_id DESC
Verbs GET, and POST on each transition below

Fields

In the order they are emitted in.

Field Type Notes
assignment_id integer
list_id integer
store_id integer 0 is the default store. A list is shared across stores; who is on it is not.
customer_id integer 0 where the assignment names a group.
customer_group_id integer 0 where the assignment names one customer.
priority integer Lower wins. A tie between two lists of the same specificity is settled on the cheaper price.
date_start string, YYYY-MM-DD | null Inclusive, and null means from always. This is not core's spelling: core's discount window ends at the start of its last day and this one does not.
date_end string, YYYY-MM-DD | null Inclusive, and null means until further notice.
live boolean Whether the list is switched on and today falls inside the window. A live assignment is what list_assigned is about.
date_added string, RFC 3339

The collection

The order is the contract, not a default. It is b2b_pricing_assignment_id DESC, it is what the cursor is keyed on, and there is no sort parameter: a second order is a second permanent promise and a second index to keep.

Walk it with &cursor=, taking the value from the previous page's page.cursor and changing nothing else. The cursor names a row rather than a position, so a row inserted mid-walk cannot shift the page under you.

It may be narrowed by these, and by nothing else — any other parameter is 400 invalid_parameter naming itself in fields.

Parameter Type Notes
filter_list_id integer
filter_store_id integer 0 is the default store.
filter_customer_id integer
filter_customer_group_id integer

Writes

Each is a POST to a named sub-address of the resource, with a JSON body and the record itself in data on success. A retry that arrives after the first call landed is refused rather than performed twice, and the client's move is to re-fetch the record.

assignment.create

Put a customer or a customer group on a list, in one store, between two dates. Exactly one of customer_id and customer_group_id. Addressed as assignment.create, with no parameter: everything it takes is in the body.

Body field Type Notes
list_id integer Required
store_id integer Optional Defaults to 0, the default store.
customer_id integer Optional Exactly one of this and customer_group_id. Sending both, or neither, is 400 invalid_parameter naming both.
customer_group_id integer Optional
priority integer Optional Defaults to 1. Lower wins.
date_start string Optional At most 10 characters. YYYY-MM-DD, inclusive. Omit for from always.
date_end string Optional At most 10 characters. YYYY-MM-DD, inclusive. Omit for until further notice.
assignment.move

Point this assignment at a different list. One atomic call, and the whole of an ERP cutover: everything before it left a list nobody was on. Addressed as assignment.move&assignment_id=.

Body field Type Notes
list_id integer Required The list to move onto. 404 not_found where there is no such list.
store_id integer Optional Left as it stands where it is not sent.
priority integer Optional Left as it stands where it is not sent.
date_start string Optional At most 10 characters. YYYY-MM-DD. Send "" to clear it.
date_end string Optional At most 10 characters. YYYY-MM-DD. Send "" to clear it.
assignment.delete

Take the assignment off. The list and its rungs are untouched; what changes is that nobody is priced by it any more. Addressed as assignment.delete&assignment_id=.

price

What one buyer pays, and what the warehouse will let them order: one entry per product they have a negotiated price on, at the quantity asked. Narrowed by filter_product_id it is the effective-price lookup for that one product, answered whether or not a list prices it. The same function the product page and the cart ask, asked from outside.

index.php?route=extension/b2b_pricing/api/v1/price
The collection this route, ordered product_id ASC
Verbs GET

Fields

In the order they are emitted in.

Field Type Notes
product_id integer
quantity integer The quantity the question was asked at, echoed back.
price money object What this buyer pays per unit, before tax and before options, in the store's own currency.
quantity_from integer The quantity that price applies from. 1 where nothing laddered.
source string One of list, opencart, base. list is a price list of this extension's; opencart is core's own discount or special; base is the product price with nothing over it.
list_id integer | null Null unless source is list.
list_name string | null Null unless source is list.
minimum integer The first quantity this buyer may order — the case rule's minimum raised to its next multiple where the two disagree. 1 means no rule of ours and core's own minimum of one.
multiple integer What a quantity above the minimum must be a multiple of. 1 allows any quantity. An ERP quoting a price also needs to know it cannot order seven.

The collection

The order is the contract, not a default. It is product_id ASC, it is what the cursor is keyed on, and there is no sort parameter: a second order is a second permanent promise and a second index to keep.

Walk it with &cursor=, taking the value from the previous page's page.cursor and changing nothing else. The cursor names a row rather than a position, so a row inserted mid-walk cannot shift the page under you.

It may be narrowed by these, and by nothing else — any other parameter is 400 invalid_parameter naming itself in fields.

Parameter Type Notes
filter_product_id integer One product, answered whether or not a list prices it — which is the effective-price lookup, and the only form that can answer opencart or base.
filter_customer_id integer What one named customer pays. A customer is priced through their own group as well as through themselves, so this is the more specific question; send it or filter_customer_group_id, never both.
filter_customer_group_id integer What a group pays where no individual arrangement is in play.
filter_quantity integer Defaults to 1. The quantity every entry is priced at, and the rung each answer is taken from.
filter_store_id integer Defaults to 0, the default store. A list is shared across stores; who is on it is not.

Embedded shapes

What a field of type array or object holds. Each is declared once and referred to from wherever it is embedded, so two copies cannot come to disagree.

row_input

One rung as a batch posts it: the product, the quantity it applies from, and the price.

Field Type Notes
identifier string The product, named the way match_on says. A product_id is sent as its decimal string, so one field carries all three spellings.
quantity integer The rung. At least 1.
price string The price, as a decimal string. A string rather than a number because a JSON number is a double in most parsers and money is not.

upsert

What a batch upsert did, including what it could not match.

Field Type Notes
list_id integer
posted integer How many rows the batch carried.
written integer How many rungs were written. The same batch posted twice writes the same number and leaves the same state.
unresolved integer How many rows named a product this catalogue does not have. Their rungs were not written.
unmatched array of string The identifiers that did not resolve, deduplicated, in the order they were sent, capped at the first hundred. unresolved is the true count.

What a client has to do

Three obligations, and a client that meets them keeps working across every non-breaking change this API is allowed to make:

  • Ignore a field you do not recognise. A new field is added without notice and without a version segment, so a client that refuses an unexpected key breaks on a change that was promised to be safe.
  • Tolerate an unrecognised member of an enum exactly as you tolerate an unrecognised field. A new state, resolution, verdict or actor is the same kind of additive change as a new field. Branch on the members you know and route the rest to a default; never switch with an exhaustive else that throws.
  • Treat a response that does not parse as this envelope as a transport failure, and retry it. A proxy's HTML error page, a truncated body, a maintenance splash: none of those came from this API, and reading them as data is how a client invents a state the store never reported.

Timestamps, and which store answered

Every timestamp is RFC 3339 with an offset, and the offset is the one of whichever store the hostname you polled resolved to — not UTC, and not the default store's. A multi-store installation polled on two hostnames answers the same moment with two different offsets. Parse to an instant and compare instants; never compare the strings, and never assume the offset is stable across stores or across a daylight-saving boundary.

What v1 owes you before you build on it

Facts about this surface that no field list states, and that a client author would otherwise find out from a support ticket:

  • This credential reads what every customer pays. An OpenCart API user opens every extension's API and core's own api/order besides, across every store in the installation, and the convention has no per-resource scoping — one extension growing its own would be thirteen chances to disagree about what a credential means. Anyone holding it can read every price list and every assignment, and therefore what any named customer is charged. Leave the API switched off unless an integration needs it: with it off these routes answer 404, as though the extension had no API at all.
  • Every write is a POST, and there is no DELETE and no PATCH on any route here. The gateway's whole verb set is GET and POST — it is a byte-compared file shared by every extension of ours that serves an API — so a write that is not a create is a POST to a named sub-address, and any other verb is 405 method_not_allowed with Allow: GET, POST. Seven of the twelve routes therefore read differently from how a REST reflex would expect: a list is created with POST price_list.create and deleted with POST price_list.delete&list_id=; its rungs are emptied with POST price_list.empty&list_id= and loaded with POST row.upsert; an assignment is created with POST assignment.create, moved with POST assignment.move&assignment_id= and removed with POST assignment.delete&assignment_id=. Nothing about the sequence changes: create the list, upsert the rungs, move one assignment.
  • A batch is capped at 1,000 rows and a body at 64 KiB, and the first cap is the one to write your loader against. An over-long batch is 400 invalid_parameter naming rows; an over-long body is 413, at a boundary that depends on how long your identifiers are. An eleven-thousand-row list is nine or twelve calls either way, and the sequence is safe to repeat: the upsert is keyed on (list, product, quantity), so a call that timed out is a call to make again.
  • A price you quote can be overtaken between the quote and the order. price answers from the lists that are live at the moment it is asked, and an assignment moved onto a different list a second later changes that answer with no notification of any kind. There is no read log, no rate limiting and no webhook: nothing here records that you read anything, and nothing tells you when something you read has moved.