Skip to content

API

Profitability Copilot 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. Profitability Copilot 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

Two read-only daily collections, for the reporting tool the business already runs: walk profit for the day-by-day profit and loss, walk product_profit for the same days broken down by product, and aggregate either into whatever period you report on.

Codes of its own

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

Code Status
status_set_empty 409
date_range_invalid 400

profit

One day of one store, as the profit and loss the merchant's own dashboard prints: the same arithmetic over that day's lines, with the day as the period its expenses amortise into.

index.php?route=extension/profitability_copilot/api/v1/profit
The collection this route, ordered date DESC, store_id DESC
Verbs GET

Fields

In the order they are emitted in.

Field Type Notes
date string, YYYY-MM-DD The order's own date, frozen when the order became real — the same day core's own revenue reports key on, so the two agree.
store_id integer 0 is the default store.
orders integer Distinct counted orders with at least one line on this day.
units integer
revenue money object Ex tax, and gross of anything that came back. What was returned is its own row below rather than netted out here.
cogs money object What the goods cost, at the cost the product carried on the day. Excludes every line with no cost — see lines_excluded.
discounts money object A positive magnitude, allocated across the day's lines.
payment_fees money object
fulfilment money object
returns money object | null Null where the merchant has nominated no return status, which is nobody has said rather than nothing came back.
charges money object What customers paid under shipping, handling and low_order_fee, once per order: order-level revenue, never allocated to a product. Signed.
unclassified money object Order-total rows this extension has no treatment for, summed. Signed.
contribution_margin money object | null An amount, not a rate. There is no margin and no markup anywhere in this API, deliberately: a rate on a row a client will sum is a rate they will average, and the average of a month of daily margins is not that month's margin. The numerator and the denominator are both here, so a client divides once, at the grain they are actually reporting at. Null where returns is.
expenses money object | null The day's share of the recurring operating expenses, amortised. Null where the merchant has recorded none.
net_profit money object | null Null where expenses or contribution_margin is.
lines_counted integer Lines with a cost, which are the lines every money figure above is made of.
lines_excluded integer Lines with no cost at all. Not zero-cost lines: they are out of revenue as well as out of cogs.
revenue_excluded money object What those excluded lines actually sold for, so the size of the gap is a figure rather than a feeling.
cost_basis object The cost_basis shape. How the counted lines were costed. estimated and backfilled are not measurements — see the disclosures.
orders_unsettled integer Orders whose own arithmetic did not reconcile against what the customer was charged. They are included in every figure above anyway.
as_of string, RFC 3339 When this row was computed. There is no change feed and no modified-since filter: re-fetch the range and compare.

The collection

The order is the contract, not a default. It is date DESC, store_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_date_from string, YYYY-MM-DD Inclusive, on the order's own date. Unfiltered is every day the store has ever recorded.
filter_date_to string, YYYY-MM-DD Inclusive.
filter_store_id integer 0 is the default store. Unfiltered is every store, which is what the credential authorises.

product_profit

One product on one day of one store: what it sold, what it cost, what came back and what was left.

index.php?route=extension/profitability_copilot/api/v1/product_profit
The collection this route, ordered date DESC, store_id DESC, product_id DESC
Verbs GET

Fields

In the order they are emitted in.

Field Type Notes
date string, YYYY-MM-DD
store_id integer 0 is the default store.
product_id integer OpenCart's own product_id, unprefixed because every id in this API is OpenCart's and there is nothing of ours to confuse it with. A product deleted from the catalogue keeps appearing here: the figures were frozen on the order.
units integer
revenue money object Ex tax, and gross of what came back.
cogs money object | null Null where no line of this product on this day carried a cost. Null is not costed; 0.0000 is genuinely free.
discounts money object A positive magnitude.
payment_fees money object
fulfilment money object
returned_revenue money object What the customer paid for the units that came back: net of their share of the line's discount, which stays in discounts, so a discount is never taken off twice. Payment fees and fulfilment stay spent. Allocated: OpenCart does not record which line a return came from — see the disclosures.
profit money object | null Revenue less cost, fees, fulfilment and discounts, less what came back net of the cost of the goods that came back with it. There is no margin and no markup, deliberately: a rate on a row a client will sum is a rate they will average, and the average of daily margins is not the period margin. Divide profit by revenue yourself, once, at the grain you are reporting at. Null where cogs is.
cost_basis string One of actual, estimated, backfilled, unknown, mixed. mixed where this product's lines on this day were not all costed the same way. unknown is a product nobody has costed, and is the one value that means the money above is null.
lines_excluded integer This product's lines on this day with no cost.
revenue_excluded money object What those lines sold for.
orders_unsettled integer The day and store's own count, repeated on each of its products. It is a fact about the day rather than about the product.
as_of string, RFC 3339

The collection

The order is the contract, not a default. It is date DESC, store_id DESC, product_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_date_from string, YYYY-MM-DD Inclusive, on the order's own date.
filter_date_to string, YYYY-MM-DD Inclusive.
filter_store_id integer 0 is the default store.
filter_product_id integer
filter_cost_basis string One of actual, estimated, backfilled, unknown. Narrows the lines a row is made of, not the rows. A page filtered to actual carries the money of the actual-costed lines only. mixed is not a stored value and is not accepted here.

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.

cost_basis

How the day's counted lines were costed, as a count apiece.

Field Type Notes
actual integer Lines costed from what the merchant recorded the product costs.
estimated integer Lines costed from the merchant's default margin, because the product had no cost of its own.
backfilled integer Lines recomputed after the fact, at a cost recorded later than the order.

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:

  • Turning this on shows every purchase price to every holder of an OpenCart API credential, and that credential cannot be scoped. One oc_api user opens every extension's API and core's own api/order besides, across every store in the installation — there is no per-extension, per-resource or per-store grant to give instead. The cost permission on the product form governs the admin screen and does not apply here and cannot: it is a user-group permission, and an API credential is not a user. So the question is not who may read margin in the admin. It is whether everybody already holding a key to this store should be able to read what the merchant pays their suppliers.
  • These figures move, and there is no change feed. A day fetched last month can read differently today: the money was frozen when the order became real, but whether the order counts is read live off its current status, and so is every return filed against it. There is no filter_date_modified_from — it would need a per-row timestamp the snapshot has no other reason to write, and it would still miss every change that is live rather than stored, which is most of them. Re-fetch the range and compare as_of.
  • Excluded lines are not zero-cost lines. lines_excluded above zero means a partial picture: those lines are out of cogs and out of revenue, because counting revenue with no cost against it would make the day look more profitable than it was. revenue_excluded is what they actually sold for, so the size of the gap is measurable rather than a feeling.
  • estimated and backfilled are not measurements. An estimated line was costed from the merchant's default margin because the product had no cost recorded; a backfilled one was recomputed after the fact, at a cost recorded later than the order. Both are the merchant asserting a number rather than the extension observing one. Only actual is a cost that existed when the order did.
  • Returns are an allocation. OpenCart records that a quantity of a product came back against an order and does not record which line it came back from, so what came back is allocated greedily across that product's lines in the order they were sold. On an order with one line of a product it is exact; on an order with two lines of the same product at different prices it is an allocation, and no record anywhere in the store could make it otherwise.
  • A day with no sales does not appear. Both collections are sparse: a row exists only where at least one counted line falls on that day and that store. So a day the merchant spent money and sold nothing is absent, and expenses summed over the days a range returns can be less than the same range's figure on their own dashboard — which amortises the expenses across every day in the period, sold or not. Sum this collection to reconcile against this collection, and use the dashboard's own range for the merchant's period figure.