API¶
Loyalty 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.
Loyalty 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 resources and no writes: movement, one entry in the points ledger
with where it came from and whether it counts, and balance, one customer's
points as OpenCart holds them and when the expirable part goes.
movement¶
One movement of points: an earn, a spend, a reversal or an expiry, where it came from and whether it still counts.
index.php?route=extension/loyalty/api/v1/movement
| One record | &movement_id= |
| The collection | the same route without &movement_id, ordered movement_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
movement_id |
integer | This extension's id for the movement. New movements carry higher ids than old ones. |
opencart_customer_id |
integer | null | Whose points these are, and null once the customer's personal data has been erased. |
store_id |
integer | |
opencart_order_id |
integer | null | The order the movement came from, and null where it came from none — a signup, a birthday, an expiry. |
points |
integer | Signed: an earn is positive, a spend, reversal or expiry negative. Points, not money, and never given a currency. |
source |
string | What the points were for. This extension's own are order, reverse, signup, birthday, expiry, redeem and redeem.refund; another extension awarding through Loyalty writes a source of its own, so the list is open and a caller must expect a value it has not seen. |
state |
string | One of posting, posted, released, orphaned, erased. Derived. posted counts toward the balance; posting is being written; released was voided before it counted; orphaned counts no longer because OpenCart's own reward row behind it is gone — worked out by looking, not read off the column, which only learns about one of the ways that row can go; erased is a movement whose customer has been erased. |
base |
money object | null | The amount an order award was worked out from, in the store's default currency, and null on every other movement. |
date_added |
string, RFC 3339 | When the points moved, which is what the expiry clock runs from. |
date_modified |
string, RFC 3339 | null | When anything about the movement last changed. Null on a movement that has not changed since the store updated to 1.1.0, because nothing recorded that moment before then. |
The collection¶
The order is the contract, not a default. It is movement_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_opencart_customer_id |
integer | One customer's ledger, which is what a CRM syncing one account asks. |
filter_opencart_order_id |
integer | |
filter_source |
string | Exact. |
filter_date_modified_from |
string, RFC 3339 | Inclusive. Every movement changed since a moment — written, posted, voided, orphaned by this extension or erased. A movement older than the store's update to 1.1.0 and untouched since is not in it; a first full walk is. |
balance¶
One customer's points as OpenCart holds them, how much of it can expire, and when.
index.php?route=extension/loyalty/api/v1/balance
| One record | &opencart_customer_id= |
| The collection | the same route without &opencart_customer_id, ordered opencart_customer_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
opencart_customer_id |
integer | Every customer account is a balance, whether or not Loyalty has ever moved points for it: points an admin added by hand, or from before this extension was installed, are points all the same. |
store_id |
integer | The store whose expiry window governs the balance: the one Loyalty last moved points on for this customer, otherwise the account's own. |
points |
integer | The balance, as OpenCart itself sums it from its own reward table — so it includes points no movement covers. Can be negative. Points, not money. |
expirable_points |
integer | How much of it can expire. What cannot is points from before the store switched expiry on and points added by hand; points minus this is that part. |
expires_on |
string, YYYY-MM-DD | null |
The day the expirable part goes, worked out as the customer's own statement page works it out, and null where expiry is off on their store or nothing is expirable. Before a warning has gone out it is the earliest it could be, and can move later; a date in the past means the store's scheduled pass has not run, and nothing expires without it. |
date_last_activity |
string, RFC 3339 | null | When Loyalty last moved points for this customer, which is what the expiry window runs from. |
date_warned |
string, RFC 3339 | null | When the expiry warning went out, and null until one has. |
The collection¶
The order is the contract, not a default. It is opencart_customer_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_store_id |
integer | The accounts belonging to one store. |
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:
- Points are not money, and nothing here converts them.
pointsandexpirable_pointsare integers with no currency. The only money is an order award'sbase, in the store's default currency. - Movements change after they are written, and
date_modifiedsays when. A movement is writtenpostingand stampedpostedorreleased, a redemption is restated, a movement whose core reward row is removed is markedorphaned, and an erasure empties it. Every one of those movesdate_modified, sofilter_date_modified_fromis the incremental read. A movement from before the store updated to 1.1.0 carriesnulluntil it next changes: start with one full walk. orphanedis worked out by looking. This extension marks a movement orphaned when core deletes reward rows by order; a reward row deleted any other way leaves the stored state alone, and the API answersorphanedanyway by checking that core still has the row. That check stamps nothing, so a movement can turnorphanedwithoutdate_modifiedmoving.- A balance has no modified date, so the collection is a full walk. It is
core's own sum, and core's reward rows carry no modified stamp and can be
added or removed by hand;
expires_onalso moves with the clock and the store's settings. Walking the cursor to the end is the synchronisation, and it is resumable. - Erasure empties a movement and keeps it. The customer is blanked and the
state becomes
erased, so the ledger still explains the orders it came from; the customer's own expiry record is deleted. A key another extension wrote for its award is kept as it was written, and whether it names the customer is that extension's to answer. - Each page of balances sums core's reward table. That is fast where core's
oc_customer_rewardhas a key oncustomer_id, which this extension adds from its scheduled pass where the host allows it; where the host refused the change, every page reads the whole table. - Nothing records that you read anything, and the credential cannot be
scoped. An OpenCart API user opens every extension's API and core's own
api/orderbesides, across every store in the installation. There is no read log, no per-credential audit trail and no rate limiting; v1 writes nothing at all.