Skip to content

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 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:

  • A run's synchronisation is insert-order, and insert-order on its own is not complete. run is append-only on created_at, so walking the cursor — or filter_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, because state, the three counts and the internal mark all move while it works, and a run can go from running to stalled with no write at all, the state being derived from a lease rather than stored. There is deliberately no modified-since filter on this resource — its date_modified is 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,stalled is exactly that set, and it is bounded by the number of feeds. generated and failed are 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 stamping generated_at — so filter_updated_at_from sees 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 spelled updated_at here and not date_modified because 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 feed is 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, and product_feed_category_map, product_feed_suggestion and product_feed_setting are 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_run carries 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 has rows_total, rows_written and rows_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.