API¶
Product Feed 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.
Product Feed 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. feed answers what an installation is
configured to produce and when each document last generated, so an agency
watching forty stores computes staleness without opening forty admins. run
answers what happened on each attempt — what it wrote, what it dropped and on
which field — and reports a run whose lease has expired as stalled rather
than waiting for a cron that may never come back.
feed¶
One feed a merchant configured: its channel, its store and language, whether it is switched on, and when it last generated.
index.php?route=extension/product_feed/api/v1/feed
| One record | &feed_id= |
| The collection | the same route without &feed_id, ordered date_added DESC, feed_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
feed_id |
integer | |
name |
string | What the merchant called it. A label rather than a key: nothing stops two feeds sharing one. |
channel |
string | Which channel's document shape the feed is written in — google, meta, bing. Answered as stored even where the installed release no longer knows the channel, because a feed configured against a channel a later release dropped is a real row somebody has to be able to find. |
store_id |
integer | 0 is the default store. Feeds are per store, and a run has no store of its own — it is the feed's. |
language_id |
integer | Core's own id for the language the document is written in. A store-local auto-increment that outlives nothing: a language deleted and re-added takes a new id, so this is a key to join on rather than a name to read. |
currency |
string | ISO 4217 alpha-3, and it is the currency the document is generated in rather than a currency on an amount. No field on either resource is money, and this one is a plain string for that reason. |
enabled |
boolean | false where the merchant has switched the feed off. A disabled feed does not run, which is what explains an absence of runs that would otherwise read as a failure. |
generated_at |
string, RFC 3339 | null | When this feed last put a complete file in place. Null where it never has — has never generated is a real answer and not a missing one. Staleness is this stamp against now, and it outlives the runs that set it. |
created_at |
string, RFC 3339 | When the feed was configured. |
updated_at |
string, RFC 3339 | Stamped by every change this extension makes to the feed, a generation included. It is what the modified-since filter reads. |
The collection¶
The order is the contract, not a default. It is date_added DESC, feed_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_updated_at_from |
string, RFC 3339 | Inclusive, against updated_at. A date alone means midnight, store-local. |
filter_updated_at_to |
string, RFC 3339 | Inclusive. |
run¶
One attempt at generating one feed: what triggered it, what it produced, how it ended, and which products it left out.
index.php?route=extension/product_feed/api/v1/run
| One record | &run_id= |
| The collection | the same route without &run_id, ordered date_added DESC, run_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
run_id |
integer | |
feed_id |
integer | The feed this run generated, on every row — so the two collections are joined without a second call per run. |
trigger |
string | One of admin, cron, cli. Which caller started it: the admin's Regenerate now button, OpenCart's own scheduler, or the extension's command line. |
state |
string | One of running, generated, failed, stalled. Derived, not read. A run stored running whose fifteen-minute lease has expired is answered as stalled — the operator who pressed Regenerate and closed the tab, or the cron that stopped — because the column is only corrected when a later pass reclaims the feed, which on a dead cron is never. generated put a complete file in place; failed hit infrastructure and left the previous file serving. |
rows_total |
integer | What one count over the feed's filtered set said when the run opened. The catalogue may have moved under a long run, so this is the denominator the run worked to rather than a fact about now. |
rows_written |
integer | Products written into the document. |
rows_rejected |
integer | Products left out. Not capped, unlike the list below: a merchant has to know that 50,000 products were dropped even when only the first few hundred are named. |
error |
string | What ended a failed run, and the empty string on every run that did not fail. The extension's own sentences, naming what it could not do rather than where: no throw site feeding this column puts a filesystem path into it. |
created_at |
string, RFC 3339 | When the run was stamped as started, before any work. It never moves, which is what makes it the cursor's key and the collection's only date filter. |
updated_at |
string, RFC 3339 | The lease every slice refreshes, so on a live run this is last progress and on a finished one it is when it finished. It is what state reads to derive stalled, which is also why there is no filter over it. |
rejections |
array of rejection |
The single fetch only, and absent from a collection entry. Which products this run left out and on which field, on the single fetch only. Capped, and deleted with the run by retention — both in the disclosures. |
The collection¶
The order is the contract, not a default. It is date_added DESC, run_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_feed_id |
integer | One feed's runs, which is what a per-feed alert wants and what the table's own key serves. |
filter_state |
string | One of running, generated, failed, stalled. Comma-separated, and against the derived state — so filter_state=stalled returns the runs whose lease has run out whether or not a later pass has got round to saying so, and filter_state=running never hands back a run that died. |
filter_trigger |
string | One of admin, cron, cli. Comma-separated. What separates scheduled generation from a human pressing the button. |
filter_created_at_from |
string, RFC 3339 | Inclusive, against created_at. 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.
rejection¶
One product a run left out, and the field it could not fill.
| Field | Type | Notes |
|---|---|---|
opencart_product_id |
integer | Core's own product id, so a caller knows whose id they are holding and can look it up in the catalogue. It may name a product the store has since deleted, and the rejection is still here. |
field |
string | The channel field the product could not fill — gtin, image_link, whatever the mapping asked for. The empty string where the assembler threw the row out outright rather than on one field. |
reason |
string | What was wrong with it, in the extension's own words. |
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:
- A run's synchronisation is insert-order, and insert-order on its own is not
complete.
runis append-only oncreated_at, so walking the cursor — orfilter_created_at_from— discovers every run there has been. Discovering a run is not the same as seeing how it ended: a run's row keeps changing after it is created, becausestate, the three counts and the internal mark all move while it works, and a run can go fromrunningtostalledwith no write at all, the state being derived from a lease rather than stored. There is deliberately no modified-since filter on this resource — itsdate_modifiedis that lease, so a filter over it would look incremental and silently drift. The complete synchronisation is two calls, not one: walk the cursor for runs created since you last looked, then re-read the ones you are still holding in a non-terminal state.filter_state=running,stalledis exactly that set, and it is bounded by the number of feeds.generatedandfailedare terminal: a run in either will not change again. - A feed's synchronisation is modified-since, and it is honest. Every change
to a field this resource emits moves
updated_at— the merchant editing the feed, switching it on or off, rotating its token, and a generation stampinggenerated_at— sofilter_updated_at_fromsees every change you can read here. One write is deliberately outside that: opening a run stamps when the feed was last attempted, which no field emits, and waking a poller for a change it cannot observe would be noise rather than honesty. It is spelledupdated_athere and notdate_modifiedbecause these tables are this extension's own rather than thin reads over core, so the convention's own field spelling applies; the filter follows the field, which is the whole point of having a vocabulary. - The feed's URL secret is never emitted, under any name. One
varchar(64)token is the whole of what makes a feed's public URL open rather than closed, so the value is the authorisation and not an identifier of anything. Through a credential that cannot be scoped — an OpenCart API user opens every extension's API across every store in the installation — emitting it would turn a read-only integration into a way to hand the merchant's entire catalogue to anybody. No field carries it and none will inside v1, and a test asserts the stored value appears in no payload, under any key, at any depth. - The merchant's configuration stays off this surface, and
feedis the narrow exception. A feed row is admitted because a run has to hang off something a caller can name. What is admitted is the feed's identity and what it produced; its category mapping, its product filter set and its schedule are not fields, andproduct_feed_category_map,product_feed_suggestionandproduct_feed_settingare not resources. This credential reads what the store produced, not how a merchant configured it. - The run's own resumption mark is not a field.
product_feed_runcarries the last product id a slice wrote, which is where the next slice continues from. It is not emitted: the glossary reserves Cursor for the paging cursor this envelope already carries, so publishing the mark under that name would put two different things under one word in one answer — and publishing it under any other name would promise an implementation detail of the slicing for twenty-four months. A caller wanting to know how far a live run has got hasrows_total,rows_writtenandrows_rejected. - Rejections are capped at 500 per run, and they are deleted with their run.
A misconfigured feed rejects every product it has; writing 50,000 rows saying
the same thing helps nobody, so the first 500 are recorded and
rows_rejected— which is not capped — says how many there really were. A list of exactly 500 entries is therefore a truncated list and must not be read as a complete one. Separately, a run's rejections are deleted along with the run itself when retention ages it out, so a caller who read a run's rejections yesterday and cannot read them today has not lost them to a bug.