Skip to content

API

Review Requests 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. Review Requests 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. ask answers what happened to every review request the store made — one order per row, its products embedded, and the review each one collected — so a reporting system can tell asked from reviewed from published without reading the merchant's screen. run answers whether the machinery behind it is running.

ask

One order asked for a review, once, with a line per product on it.

index.php?route=extension/review_requests/api/v1/ask
One record &ask_id=
The collection the same route without &ask_id, ordered date_added DESC, ask_id DESC
Verbs GET

Fields

In the order they are emitted in.

Field Type Notes
ask_id integer
store_id integer The store the order was placed on. Carried on the row rather than derived, because a pass runs per store and the mark it moves is per store too.
opencart_order_id integer Core's own order id, and the key an integrator joins on. Unique across this resource: one order is asked once, kept by a UNIQUE key rather than by a WHERE clause somebody has to remember.
opencart_language_id integer Core's own id for that language, and the key oc_product_description is joined on to name the products the email listed. It is a store-local auto-increment that outlives nothing: a language deleted and re-added takes a new id, and the old one may be handed to a different language — so read opencart_language_code to know which language this was, and this one only to join.
opencart_language_code string The language the email was written in, as the code the store registered it under — en-gb, nl, whatever the merchant typed. The recipient reference, recorded on the row when the request was sent rather than resolved on read, so it still names the right language after the language table has changed under it. The empty string on a row sent before this field existed whose language has since been deleted, which is the one thing nothing can reconstruct.
email string Returned in full, and the empty string once the row is erased. It is the address the request went to and the identity a suppression is keyed on; the credential cannot be scoped, so redacting it would remove no access from anybody.
backfill boolean true where the ask was found by the initial harvest rather than by the live frontier. Fresh requests are sent ahead of the harvest, so this is why a recent order may go out before an older one.
state string One of upcoming, asked, reminded, interrupted, failed, waiting, published, not_asked, unsubscribed. Derived, never stored. upcoming is discovered and not yet due; interrupted is a pass that died between claiming the ask and hearing back from the transport, and it counts as asked; waiting and published split on whether core has approved the review, which is read live on every call.
skip_reason string | null One of status_changed, unsubscribed, no_lines. Why the ask was deliberately not sent, and null on every ask that was. unsubscribed is the one split out into a state of its own, so this is what tells the other two apart.
refunded boolean The order behind this ask reached a status that means the money came back. A statement about the order now, not about the day it was asked.
review_gone boolean A review this ask collected is no longer in oc_review. Core deletes a product's reviews along with the product and says nothing, so this is how that arrives.
unlinked boolean A review was submitted through this ask and the write could not be proved to be ours, so no opencart_review_id was recorded on the line. The customer's review exists; the link does not.
acknowledged boolean The merchant has looked at the flags above and decided. It clears their own exception count and changes no flag, because the facts stay true.
error string What the transport threw, verbatim, and the empty string on every ask it did not. Emptied by erasure along with the address, because a bounce message quotes the address.
purged boolean true where retention has erased this row: the address, the token and the error are gone and everything else is still here. See the disclosures — this is the field that tells erased from never captured.
date_reached string, RFC 3339 | null The earliest moment the order reached the status the merchant chose, copied from core's oc_order_history. There is deliberately no due date on the row: the delay is a setting, and a stored due date would freeze every queued ask at whatever the delay was on the day it was discovered. Null only where core's own history row carries a zero date, which is a store migrated from an older MySQL.
date_sent string, RFC 3339 | null When the request was handed to the transport. Null until it was, and it is not a delivery receipt — nothing here can speak to delivery, which is why the state is called asked.
date_reminded string, RFC 3339 | null When the one nudge went out. Null on an ask that was never reminded, and what makes reminded a state rather than a column.
date_checked string, RFC 3339 | null When the integrity sweep last re-examined this ask's flags. How current the three booleans above are.
date_closed string, RFC 3339 | null When the ask stopped being able to change — the token expired and no line is still open. Retention counts its window from here, so this plus purged is the whole erasure story.
date_added string, RFC 3339 When a pass discovered the order. Later than date_reached by however long the store went without a pass.
date_modified string, RFC 3339 Stamped by every write this extension makes to the ask, including the erasure. See the disclosures for the one change it does not see.
lines array of line One entry per product the order was asked about, on the single fetch and on the collection alike. A line is six scalars, so a page of asks carrying theirs is still a page.

The collection

The order is the contract, not a default. It is date_added DESC, ask_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 upcoming, asked, reminded, interrupted, failed, waiting, published, not_asked, unsubscribed. Comma-separated. Narrows on the derived state, so published costs the same live count against oc_review that the field does.
filter_store_id integer 0 is the default store. Unfiltered is every store, which is what the credential authorises.
filter_opencart_order_id integer At most one ask answers to it.
filter_date_added_from string, RFC 3339 Inclusive, against the moment a pass discovered the order. A date alone means midnight, store-local.
filter_date_added_to string, RFC 3339 Inclusive.
filter_date_modified_from string, RFC 3339 Inclusive. Every write this extension makes to an ask moves this stamp, the erasure included. Read the disclosures before building a sync on it alone.

run

One pass of the sending machinery over one store: what it found, what it sent, and how it ended.

index.php?route=extension/review_requests/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
store_id integer One pass holds one store, and the lease that protects the store's discovery cursor is this row.
trigger string One of cron, admin, cli. Which caller started it: the cron URL, the button on the settings screen, or the command line.
state string One of running, completed, failed, stalled, held. Derived, not read. A pass stored as running whose lease has expired is answered as stalled — the process the host killed, or the admin who pressed the button and closed the tab — because the column is only corrected when a later pass reclaims the store, which may be an hour away. held is a pass that deliberately sent nothing, with the reason in error.
discovered integer Asks this pass wrote that were not there before.
sent integer Requests and reminders handed to the transport without a throw.
failed integer Sends that threw.
skipped integer Asks the pass decided not to send. Each one's reason is on its own ask, as skip_reason.
error string What went wrong on a failed pass, or why a held one held. The empty string on a pass with nothing to say.
date_added string, RFC 3339 When the pass was stamped as started, before any work.
date_modified string, RFC 3339 The lease every discovery batch and every send refreshes, so on a live pass 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 nothing corrects it afterwards — see 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_state string One of running, completed, failed, stalled, held. Comma-separated, and against the derived state — so filter_state=stalled returns the passes whose lease has run out whether or not a later pass has got round to saying so.
filter_store_id integer 0 is the default store.
filter_trigger string One of cron, admin, cli. Comma-separated.
filter_date_added_from string, RFC 3339 Inclusive. A date alone means midnight, store-local.
filter_date_added_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.

line

One product an order was asked about, and the review it collected.

Field Type Notes
line_id integer
opencart_product_id integer Core's own product id. It may name a product core has since deleted, and the line is still here — which is what keeps the verified-purchase badge honest about an order that was really placed.
state string One of pending, reviewed, skipped. A submission in flight reads as pending, because an abandoned one is returned there.
skip_reason string | null One of product_gone, product_excluded. Null on every line that was not skipped. product_excluded is a line the merchant chose not to ask about.
opencart_review_id integer | null Core's own review id, and the whole of what makes a review a verified purchase. Null where the line has no review yet, and null on a reviewed line whose write could not be proved to be ours — the ask carries unlinked when that happened. It may also point at a row core has deleted, in which case the ask carries review_gone.
date_added string, RFC 3339 When the ask was discovered, which is when its lines were written.
date_modified string, RFC 3339 Stamped by every write to the line. It does not move the ask's own stamp, which is stated in the disclosures.

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:

  • filter_date_modified_from sees every change this extension makes and one it does not. Every statement that writes to an ask stamps the column, the erasure included, so an incremental walk is a real mechanism here rather than the polite fiction it would be elsewhere. The exception is exact: the split between waiting and published is core approving the review, in core's own Catalog > Reviews screen, and nothing about that touches a column this extension owns. So an ask can move from waiting to published — the single most interesting transition on this resource — without its stamp moving, and a caller polling only on date_modified will hold a stale state indefinitely. Two smaller cases behave the same way: a line has its own date_modified and moving it does not move the ask's, and a review core has deleted only surfaces once the integrity sweep next runs. The complete synchronisation is two calls, not one: walk filter_date_modified_from for what changed, then re-read filter_state=waiting, which is a bounded set and is exactly the rows that can still move without telling you. A periodic full walk of the cursor remains the authoritative answer.
  • Erasure empties the row and keeps it, and purged is how you tell. Retention blanks the address, the token and the transport error and sets purged with date_closed; nothing is deleted, ever. That is not a filing preference — the verified-purchase badge on the storefront is a probe against these lines joined through the ask, so deleting a row would strip a badge off a public page while the disclosure sentence beside it went on explaining a verification that no longer rendered. For a caller it means an erased ask is still readable and still countable, with email and error as empty strings: erased and never captured are different rows and this field is the difference. Nothing else about the ask changes, so the state, the stamps and the lines are all still there.
  • 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/order besides, across every store in the installation. The address is what the request was sent to and what a suppression is keyed on, so redacting it would remove no access from anybody and would leave the resource unable to say who was asked. There is no way to authorise a caller for one store, one order or one customer.
  • The landing and unsubscribe token is never emitted. One oc_token(40) string opens an order's review form and, on the same page, unsubscribes its customer, with no second factor — so it is authority 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 write reviews as, or silence, any customer of the store. The suppression list is out of v1 for the other half of the same reason: it is the audience, and the resource would hand every credential holder the addresses of everyone who asked not to be emailed.
  • Nothing here writes, and resend and acknowledge are deliberately not transitions. Both are real named actions on the merchant's own screen and both are admin work rather than integration work: a resend is a judgement about one customer, and acknowledging is a merchant saying they have looked. Adding either later is additive under the promise. What no release will add is writing a review through this API — that is words in a customer's mouth, and the verified-purchase badge is a public claim that a real person wrote what is under it.
  • 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.