Skip to content

API

Abandoned Cart Recovery 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. Abandoned Cart Recovery 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. episode answers what happened to every abandoned checkout the store noticed — the unfinished orders behind it, every email attempted or withheld, and whether a paid order followed — so a reporting system can measure recovery without reading the merchant's screen. run answers whether the machinery behind it is running.

episode

One abandonment by one address on one store, with its evidence and its sends.

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

Fields

In the order they are emitted in.

Field Type Notes
episode_id integer
store_id integer The store the checkout was abandoned on. 0 is the default store.
email string The address as core recorded it on the order, in full, and the empty string once the episode is purged. Matching is case-insensitive by the table's collation, so two spellings of one address are one episode.
opencart_customer_id integer Core's own customer id, and 0 for a guest — which on most stores is most abandoned checkouts.
opencart_language_code string The language the shopper checked out in, as the code the store registered it under, recorded when the episode opened. The empty string where core's order carried none.
position string One of none, reminder_only, marketing_sequence. How this store might email this shopper, stamped once when the episode opened and never moved. none is an episode opened before the merchant chose, and it is never emailed. The store narrowing later withholds sends; the store widening later adds none.
backfill boolean true where the merchant's one-off backfill found the checkout rather than the live pass. A backfilled episode gets one email at most.
state string One of waiting, emailed, recovered, expired, not_emailed, send_failed, send_interrupted, stopped, cart_empty. Derived from the episode and its sends on every call, never stored — the same derivation the merchant's Episodes list shows. send_interrupted is a send that died inside the mail call; it counts as attempted and is never retried, because the message may have gone.
close_reason string | null One of converted, expired, no_lines, unsubscribed, suppressed. Why the episode closed, and null while it is open.
skip_reason string | null One of no_position, position_narrowed, no_newsletter, stopped_by_hand. The reason on the most recent send that was withheld, and null where none was. Each send's own is on sends.
coupon_issued boolean | null Whether a recovery coupon was minted for this episode. The code itself is never emitted: it is money. Null once the episode is purged, because the code is what retention blanks and nothing else records that it existed.
opencart_converted_order_id integer | null Core's id for the paid order that closed the episode converted, and null on every episode that did not.
recovered_total string | null That order's total as a decimal string with four places, in the store's default currency — see the disclosures. Null on an episode that did not convert: "0.0000" would be a recovery worth nothing, which is a different statement from no recovery.
purged boolean true where retention or an erasure request has blanked the address, the token, the coupon code and the error. Everything else is kept, so the recovered total does not shrink retroactively.
date_opened string, RFC 3339 | null The abandonment: the first evidence order's own last-modified stamp, not the moment a pass noticed it. The delays count from here. Null only where core's order carried a zero date, which is a store migrated from an older MySQL.
date_last_send string, RFC 3339 | null When the latest send was attempted. Null until one was.
date_clicked string, RFC 3339 | null The first time the cart link was followed, never overwritten. A diagnostic: the recovered total is not computed from it.
date_closed string, RFC 3339 | null When the episode closed, and null while it is open. Retention counts from here.
date_added string, RFC 3339 When a pass opened the episode. Later than date_opened by however long the store went without a pass.
date_modified string, RFC 3339 Stamped by every write to the episode and by every write to one of its sends, the purge and the erasure included.
evidence array of evidence Every unfinished core order this episode holds, oldest first. Core writes a fresh one per checkout session, so a shopper may leave two or three.
sends array of send One entry per email attempted or withheld, in ordinal order. A send is written when it is attempted, never queued ahead, so a send that is not due yet is not here.

The collection

The order is the contract, not a default. It is date_added DESC, episode_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_state string One of waiting, emailed, recovered, expired, not_emailed, send_failed, send_interrupted, stopped, cart_empty. Comma-separated. Narrows on the derived state, the same one the field carries.
filter_opencart_order_id integer The episode holding that core order as evidence, or closed converted by it.
filter_date_added_from string, RFC 3339 Inclusive, against the moment a pass opened the episode. 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 to an episode or to one of its sends moves this stamp, the purge and the erasure included.

run

One pass over one store: what it found, what it sent, and how it ended.

index.php?route=extension/abandoned_carts/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.
trigger string One of cron, admin, cli. Which caller started it: OpenCart's scheduler, 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, because the column is only corrected when a later pass reclaims the store. held is a pass that deliberately sent nothing, with the reason in error.
discovered integer Episodes this pass opened.
sent integer Emails handed to the mail engine without a throw.
failed integer Sends that failed.
skipped integer Sends the pass withheld. Each one's reason is on its episode.
error string What went wrong on a failed pass, or why a held one held. The empty string otherwise.
date_added string, RFC 3339 When the pass was stamped as started, before any work.
date_modified string, RFC 3339 The lease every batch refreshes: last progress on a live pass, the finish on a finished one. It is what state reads to derive stalled.

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, against the derived state.
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.

evidence

One unfinished core order an episode holds.

Field Type Notes
opencart_order_id integer Core's own order id, the unfinished checkout. Unique across every episode: one order is evidence of one abandonment.
date_added string, RFC 3339 When a pass attached the order to this episode.

send

One email of the sequence, attempted or withheld.

Field Type Notes
ordinal integer 1, 2 or 3: which email of the sequence. One row per ordinal, ever.
position string One of none, reminder_only, marketing_sequence. The position the send went, or was withheld, under: the lower of the episode's and the store's at the time.
state string One of pending, sending, sent, failed, skipped. sending is stamped before the mail call, so a process that died inside it leaves this. sending, sent and failed are never retried by the pass.
skip_reason string | null One of no_position, position_narrowed, no_newsletter, stopped_by_hand. Why it was withheld, and null on every send that was not skipped.
error string What the mail engine said on a failed send, verbatim, and the empty string otherwise. Blanked with the episode, because a bounce can quote the address.
date_sent string, RFC 3339 | null When it was handed to the mail engine. Null on a skipped send. Not a delivery receipt.

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:

  • recovered_total is in the store's default currency, and no field says which. It is core's oc_order.total, which core keeps in the base currency whatever the shopper paid in, and core has no per-order column naming that currency. Read it from the store's own settings. It is a decimal string rather than a number, because a float would throw away the point of a decimal.
  • The recovered total counts orders that followed an email, not orders the email caused. An episode closes converted on a paid order from the same address at or after the abandonment. That is the figure the merchant's own screen shows, and this API does not make it any stronger.
  • Erasure blanks the episode and keeps it, and purged is how you tell. Retention and an erasure request both empty the address, the token, the coupon code and every send's error, set purged and stamp date_modified; nothing is deleted. Erased and never captured are different rows, and this field is the difference.
  • The address is returned in full, and the credential cannot be scoped. An OpenCart API user opens every extension's API across every store in the installation. There is no way to authorise a caller for one store or one shopper, and that is why there is no filter_email and no suppression resource.
  • The cart token and the coupon code are never emitted. The token opens the shopper's cart and unsubscribes them, with no second factor; the code is a discount. Neither is a field under any name, and neither will be inside v1.
  • Nothing here writes. Sending, resending, stopping and suppressing are the merchant's own judgements about one shopper, made on the Episodes screen behind a permission. Nothing records that you read anything, either: there is no read log and no rate limit.