Skip to content

API

Wishlist 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. Wishlist 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, answering two different populations: watch is a signed-in customer waiting on one product's price or stock, and demand is how many people — guests included — have a product saved right now.

watch

One signed-in customer asking to hear about one product, and the two baselines the answer is measured against.

index.php?route=extension/wishlist/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 A surrogate. What identifies this row to the rest of the extension is the customer, the store and the product together; this integer exists so the collection has something to break ties on.
store_id integer The store the customer was on when they asked. Carried on the row rather than derived, because the alert sweep runs at store 0.
opencart_customer_id integer Always a real account. There is no guest watch: a guest has no address to mail, and capturing one would be consent, storage and an unsubscribe path this extension deliberately does not have.
opencart_product_id integer
price_alert boolean Whether this customer asked to hear about a price drop on this product.
stock_alert boolean Whether they asked to hear about it coming back into stock. Both may be off at once: the row survives so the baselines below do.
basis money object What a price drop is measured against — the figure the product page showed a signed-out visitor when this person saved it, tax included where the store includes it, in the store's default currency. Not an offer, not a price anybody was quoted, and reset to the notified value every time an alert goes out.
stock_basis integer The quantity last observed for this product, which is what a back-in-stock alert is measured against. Reset the same way.
created_at string, RFC 3339 When the customer first asked about this product. Spelled as Back In Stock's watch spells it, because it is the same noun.
price_notified_at string, RFC 3339 | null When the last price-drop mail for this row went out, null until one has. A throttle rather than a state: it does not suppress the next alert, it dates the last one.
stock_notified_at string, RFC 3339 | null The same, for back-in-stock mail.

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_store_id integer 0 is the default store. Unfiltered is every store, which is what the credential authorises.
filter_opencart_customer_id integer
filter_opencart_product_id integer
filter_price_alert integer 1 for the rows watching a price, 0 for the rows that are not.
filter_stock_alert integer 1 for the rows waiting on stock, which is the question a reordering system asks.
filter_created_at_from string, RFC 3339 Inclusive. A date alone means midnight, store-local.
filter_created_at_to string, RFC 3339 Inclusive.

demand

How many people have one product saved right now, split by whether they have an account — the only thing this API says about guest wishlists.

index.php?route=extension/wishlist/api/v1/demand
One record &opencart_product_id=
The collection the same route without &opencart_product_id, ordered total DESC, opencart_product_id ASC
Verbs GET

Fields

In the order they are emitted in.

Field Type Notes
opencart_product_id integer Core's product id, which is the only identity this resource has. A product deleted from the catalogue keeps its saved rows and so keeps appearing here.
total integer How many people have this product saved right now, across both kinds of owner. Not a tally of everyone who ever wanted it.
customers integer How many of them have an account.
guests integer How many of them do not, which is what says whether guest lists are being used at all.

The collection

The order is the contract, not a default. It is total DESC, opencart_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.

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 Which store's saves are counted. Read the fifth disclosure before relying on it: on OpenCart 4.0.2.0 a signed-in customer's saved products carry no store at all.
filter_opencart_product_id integer One product's figures, which is the authoritative way to ask about a product rather than looking for it in the ranking.

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:

  • Erasure here is deletion, so there is nothing to read back. A watch is not an accounting record: deleting the customer deletes their watches, unsubscribing deletes them, and the shopper removing the product deletes the one. None of it leaves a tombstone, no date_erased exists and none can — the row is gone. 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 watch collection is the authoritative list, and a watch_id the walk no longer returns is a row that no longer exists, indistinguishable from one that never did.
  • Guest wishlists are not readable and will not become readable. A guest list is keyed on a 32-character token handed to the shopper in a URL, and that token is authority over the list rather than a name for it — anyone holding it can read and share the list. Nothing identifying is stored beside it, deliberately, so there is no other id to address a guest by. demand is the whole of what this API says about guest lists: how many of them hold a product, and never which ones or whose.
  • demand cannot be walked to the end. It has no cursor — it is a GROUP BY with no timestamp and no id but core's product — so limit is the whole of its paging and a page is the top of the ranking, not the first slice of a set you can finish. Two hundred rows is the ceiling. A caller who needs a figure for a specific product asks for that product with filter_opencart_product_id rather than looking for it in the list; a caller who needs every product's figure walks their own catalogue and asks per product.
  • Every demand figure is live, and there is no history. Each number counts rows on somebody's list at the moment you asked. A shopper removing a product lowers it, and nothing anywhere records that it was ever higher. It is not a tally of everyone who ever wanted the product and it must not be reported as one.
  • On OpenCart 4.0.2.0 a signed-in customer's saved product has no store, so filter_store_id is half-blind. Core added store_id to oc_customer_wishlist after 4.0.2.0. Where the column is absent every customer save counts as store 0, so on that release filter_store_id=0 returns those saves whichever store they were made on, and any other store id returns guest saves only. Guest saves carry a real store on every supported release, because that table is this extension's own.
  • 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/order besides, 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, so unlike an extension with transitions there is not even a history row naming the actor.