API¶
Back In Stock 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.
Back In Stock 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¶
One read resource and no writes, for the system that decides what to reorder:
filter the watch collection by product to see who is waiting for it, or walk
the cursor to the end for the authoritative list across the whole installation.
watch¶
One shopper waiting on one counter — a product, or one of its option values — as the capture form recorded it.
index.php?route=extension/back_in_stock/api/v1/watch
| One record | &watch_id= |
| The collection | the same route without &watch_id, ordered created_at DESC, watch_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
watch_id |
integer | |
store_id |
integer | The store the shopper signed up on. Carried on the row rather than derived, because the pass runs at store 0. |
opencart_product_id |
integer | |
opencart_product_option_value_id |
integer | 0 where the watch is on the product rather than on one of its option values. This is the id of the link row in oc_product_option_value, which is what makes the pair a counter. |
opencart_option_id |
integer | 0 on a product-level watch. Carried because an admin product save deletes and re-inserts every option row, so the link id above may be reassigned — this pair is the fallback the extension itself resolves through. |
opencart_option_value_id |
integer | 0 on a product-level watch. The shared value id, not the link row's. |
opencart_customer_id |
integer | 0 for a shopper who was not signed in, which is most of them: the whole proposition is no account. |
email |
string | Returned in full. It is the only identity a watch has, and the credential cannot be scoped. |
language_code |
string | The language the shopper signed up in, which is the language their alert is written in. |
state |
string | One of pending, confirmed, claimed, sent, parked. A pending watch has not proved its mailbox and is not on the waiting list. parked is a person still waiting whose last three sends threw. |
attempts |
integer | Sends that threw. The reason behind parked, never the gate: the state is. |
wording_hash |
string | Hex sha256 of the exact notice this person read. The text itself is on the single fetch. |
created_at |
string, RFC 3339 | When the sign-up was captured. A resend of the confirmation is a fresh capture, so this moves — and a watch that moves mid-walk is missing from that walk, as the disclosures below say. |
confirmed_at |
string, RFC 3339 | null | Null until the mailbox is proved. Non-null is what makes the watch countable and mailable. |
alert_sent_at |
string, RFC 3339 | null | Null until the alert went out. |
expires_at |
string, RFC 3339 | When this row is deleted. Stored rather than derived, because the period is inside the notice the shopper read: lowering the setting does not shorten a life already promised. |
notice |
object | null | The notice shape. The single fetch only, and absent from a collection entry. The wording this person consented to. Null only where the row's hash resolves to no text, which is a store somebody has swept by hand. |
The collection¶
The order is the contract, not a default. It is created_at DESC, watch_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_state |
string | One of pending, confirmed, claimed, sent, parked. Comma-separated. |
filter_store_id |
integer | 0 is the default store. Unfiltered is every store, which is what the credential authorises. |
filter_opencart_product_id |
integer | |
filter_opencart_product_option_value_id |
integer | 0 lists the product-level watches. Narrows to one counter when given beside a product. |
filter_opencart_customer_id |
integer | 0 lists the watches taken by shoppers who were not signed in. |
filter_created_at_from |
string, RFC 3339 | Inclusive. A date alone means midnight, store-local. |
filter_created_at_to |
string, RFC 3339 | Inclusive. |
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.
notice¶
The consent wording one shopper read, as they read it.
| Field | Type | Notes |
|---|---|---|
wording_hash |
string | The same hash the watch carries. Two watches on the same wording share one notice. |
body |
string | The notice as it was rendered and read, not a template. |
date_added |
string, RFC 3339 | When this wording was first minted, which is the first time anybody signed up under it. |
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:
- Erasure here is deletion, so there is nothing to read back. A returns
request that is erased stays readable with its contact fields emptied and a
date_erasedset; a watch does not. A watch is not an accounting record — nothing outlives the person's interest in it — soWithdrawal::erase()removes the rows outright and an erased watch is simply gone between two polls. It is deliberately indistinguishable from a shopper who withdrew, from a watch that expired on its promised schedule, and from one whose alert went out sixty days ago. A resource that disappears is the only erasure signal this API has, which means a synchronisation that only ever adds rows will keep personal data this store has destroyed. Reconcile by absence — a full walk of the collection is the authoritative list, read with the caveat below — rather than by waiting for a tombstone that is never coming. - A watch re-captured during a walk can be missing from that walk. When a
shopper signs up again for a counter they already have a
pending,sentorparkedwatch on, the row keeps itswatch_idand itscreated_atis re-stamped to now — which moves it above your cursor, into the pages you have already read. The walk skips it rather than showing it twice, and the next walk finds it at the top. So one walk alone does not prove a watch is gone: before deleting a local copy that a walk did not return, fetch it by id —404 not_foundis the deletion — or require it to be absent from two consecutive walks. - The contact address is returned in full, 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. The address is the only identity a watch has — most shoppers here have no account at all — so redacting it would remove no access from anybody and would leave the resource unable to say whose watch it is. - The confirmation and unsubscribe token is never emitted. One 32-character string both proves a mailbox and withdraws the watch, with no second factor, so it is a bearer credential rather than an identifier. No field carries it and none will inside v1: through a credential that cannot be scoped it would let any holder confirm or withdraw on any shopper's behalf.
- There is no incremental filter and no deletions feed. The collection is a resumable full walk in one promised order, and that is the whole synchronisation mechanism. Every stamp on a watch marks a particular event rather than last changed, so nothing here can tell you a row moved without you re-reading it — which is stated as a limit rather than papered over with a column that would regress the first time a send failed.
- Nothing records that you read anything. There is no read log, no per-credential audit trail and no rate limiting. v1 writes nothing at all, so unlike an extension with transitions there is not even a history row naming the actor: a read leaves nothing, on either side.