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
switchwith an exhaustiveelsethat 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/orderbesides, 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 answer404, as though the extension had no API at all. - Every write is a
POST, and there is noDELETEand noPATCHon any route here. The gateway's whole verb set isGETandPOST— 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 aPOSTto a named sub-address, and any other verb is405 method_not_allowedwithAllow: GET, POST. Seven of the twelve routes therefore read differently from how a REST reflex would expect: a list is created withPOST price_list.createand deleted withPOST price_list.delete&list_id=; its rungs are emptied withPOST price_list.empty&list_id=and loaded withPOST row.upsert; an assignment is created withPOST assignment.create, moved withPOST assignment.move&assignment_id=and removed withPOST 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_parameternamingrows; an over-long body is413, 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.
priceanswers 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.