# Stocky API v2 — reference Captured 8 August 2026 from `https://stocky.shopifyapps.com/api/docs/v2.html`. Kept locally because the app **and its documentation** go offline on **31 August 2026**. --- ## Authentication Two headers. No OAuth, no token exchange. ``` GET https://stocky.shopifyapps.com/api/v2/purchase_orders.json Store-Name: example.myshopify.com Authorization: API KEY=abcdefghijklmnopqrstuvwxyz123456 ``` The merchant generates the key at `https://stocky.shopifyapps.com/preferences/api` while signed in. **The session cookie does not work.** Verified 8 Aug 2026: a request to `/api/v2/tax_types.json` from a signed-in browser tab, with the session cookie sent and no `Authorization` header, returns **401**. The API key is mandatory and there is no alternative — no other API version exists, and nothing returned 403 during a full probe, so there are no gated routes to unlock instead. **The API may be switched off on the store.** Third-party integration docs (Report Pundit, Jan 2026) instruct merchants to request the key by contacting Shopify: only once Support enables the option does the key appear under Preferences → API access. So a merchant may open that page and find nothing. Put those two facts together and the consequence is sharp: **if API access is not enabled, part of the data is unreachable by any means.** Session-based extraction does not substitute for the key, because purchase orders with their line items, supplier `id`, `company_name`, `phone_toll_free` and the timestamps exist only in the API. The two paths are complementary, not alternatives. Operationally, this is the one step with an external dependency and an unpredictable turnaround. Ask a merchant to open `/preferences/api` and say what they see **before** anything else. If the section is missing, open a Shopify Support ticket that same day — everything else in this process takes about an hour. **CORS is open.** Verified 8 Aug 2026 from an unrelated origin: the preflight passes and `Authorization` / `Store-Name` are both allowed, so a browser page on any domain can call this API directly. This is unusual for an internal API and should not be assumed to survive. --- ## The API is read-only Every documented endpoint is a `GET`. There are no `POST`, `PATCH` or `DELETE` operations — confirmed both by the resource list and by Shopify Partners on the community forum. Nothing built against this API can modify merchant data. --- ## Resources | Resource | Endpoints | | --- | --- | | Purchase Order | `GET /api/v2/purchase_orders.json`
`GET /api/v2/purchase_orders/{id}.json` | | Suppliers | `GET /api/v2/suppliers.json`
`GET /api/v2/suppliers/{id}.json` | | Stock Adjustment | `GET /api/v2/stock_adjustments.json`
`GET /api/v2/stock_adjustments/{id}.json` | | Stock Adjustment Item | `GET /api/v2/stock_adjustment_items.json`
`GET /api/v2/stock_adjustment_items/{id}.json` | | Tax Type | `GET /api/v2/tax_types.json`
`GET /api/v2/tax_types/{id}.json` | | **Stock Transfer** *(undocumented)* | `GET /api/v2/stock_transfers.json` | ### Probe results, 8 Aug 2026 `/api/v2/stock_transfers.json` is absent from the documentation index but responds 200 with a `{"stock_transfers": [...]}` envelope. Note the asymmetry: the HTML route `/stock_transfers` now redirects into the Shopify admin because the feature has migrated, yet the API to that data still answers. On a store with transfer history this may be the only remaining way to read it — worth checking early, since the interface is already gone. Everything else probed came back 404. Specifically **there is no vendors endpoint** — not `vendors`, `vendor`, `vendor_products`, `lead_times`, `limits` or `min_orders`. Also absent: `products`, `variants`, `locations`, `stock_takes`, `settings`, `preferences`, `custom_fields`, `users`, `shop`, `account`, `purchase_items`. No other API version exists: `/api/v1`, `/api/v3` and unversioned `/api` all 404. And nothing returned 401 or 403 during the probe, so there are no gated routes worth trying to unlock — a route either exists or it does not. **Consequence for vendors.** Lead times live on the vendor, and the API cannot enumerate vendors at all, so their ids have to come from the HTML. The best source is the vendor filter select on `/reports/variants` (`select[name="q[product_vendor_id_eq]"]`): it renders `` for the full set, giving id and name together in one request, which neither the list page nor `vendors.csv` does. --- ## Pagination — read this before writing a loop `since_id` **does not mean the same thing on every resource.** | Resource | Documented meaning of `since_id` | Cursor advances on | | --- | --- | --- | | `suppliers` | IDs **greater than** the provided ID | `max(ids)` | | `purchase_orders` | IDs **less than** the provided ID | `min(ids)` | | others | not documented | detect from ordering | Purchase orders come back **newest first** (the official example response lists `id: 2` before `id: 1`). A loop written for the suppliers semantics will fetch the first page of purchase orders and then stop, appearing to succeed. On a store with three years of history that silently loses everything but the most recent 250 orders. Defensive pattern that works regardless: 1. Track every id already seen. 2. Infer direction from the first page: `ids[0] > ids[-1]` means descending, so use `min`; otherwise `max`. 3. Stop when a page contains no id you have not already seen. With the dedupe guard in place, guessing the direction wrong degrades to a slow one-record-per-page crawl instead of truncating or looping forever. `offset` also exists but the docs explicitly say not to use it for pagination, and it is ignored when `since_id` is present. **`show` returns nothing extra.** Checked against the documentation on 8 Aug 2026: `GET /api/v2/suppliers/{id}.json` returns exactly the same 18 fields as the list endpoint, unwrapped (a bare object instead of a `suppliers` array). No lead time, no MOQ, no payment terms. The natural assumption that a Rails `show` action carries a richer serializer than `index` does not hold here — so there is no point walking every supplier id hoping for more. **Avoid `GET /api/v2/purchase_orders/{id}.json`.** A merchant reported on the Shopify community that the endpoint returns 404 for purchase orders that the list endpoint had just returned. It is also unnecessary: the list response already embeds `purchase_items` in full. --- ## Purchase Order ### `GET /api/v2/purchase_orders.json` **Parameters** | Param | Notes | | --- | --- | | `confirmed_since` | ISO 8601. Orders confirmed on or after this date. | | `updated_since` | ISO 8601. Orders updated on or after this date. | | `status` | `draft`, `draft-archived`, `draft-unarchived`, `confirmed`, `confirmed-archived`, `confirmed-unarchived`, `archived`, `unarchived` | | `limit` | Records per page. | | `offset` | Present, but not for pagination. | | `since_id` | IDs **less than** the provided ID. | **Fields** | Field | Type | Notes | | --- | --- | --- | | `id` | Integer | | | `number` | String | Merchant-overridable. Falls back to `sequential_id`. | | `sequential_id` | Integer | | | `invoice_number` | String | Supplier's own reference. | | `created_at` / `updated_at` / `generated_at` | DateTime | ISO 8601 | | `ordered_at` | DateTime | **Null means the PO was never confirmed.** | | `expected_on` / `ship_on` / `payment_due_on` / `invoice_date` | Date | | | `archived` | Boolean | | | `paid` | Boolean | | | `supplier_name` | String | | | `supplier_id` | String | Optional. Absent when the PO is tied to a *vendor* rather than a supplier record — in that case only `supplier_name` identifies it. | | `currency` | String | Supplier's currency. | | `adjustments` / `adjustments_local` | Decimal | Supplier's currency / merchant's currency. | | `shipping` / `shipping_local` | Decimal | Same split. | | `shipping_tax_type` | Integer | Look up the rate via the tax type endpoint. | | `shopify_receive_location_id` | Integer | Maps to a Shopify Location. | | `purchase_items` | Array | Embedded line items, see below. | **`purchase_items[]`** | Field | Type | Notes | | --- | --- | --- | | `id` | Integer | | | `sku` | String | Can be empty. | | `inventory_item_id` | Integer | Shopify InventoryItem — the join key back into Shopify. | | `product_title` / `variant_title` | String | | | `asin` | String | Amazon identifier. | | `quantity` | Integer | | | `status` | String | e.g. `not delivered` | | `retail_price` | String | | | `cost_price` | String | What was paid. Frequently `null`. | | `supplier_cost_price` | String | Frequently `null`. | | `account_code` | String | Accounting code, e.g. `300`. | | `tax_type_id` | Integer | | | `accounting_tax_type` | String | e.g. `HST` | | `received_at` | Date | **Null means not yet received.** | | `updated_at` | String | ⚠ Not ISO. Rendered as `DD/MM/YYYY HH:MM` using the merchant's own date format from Preferences → General. Parse accordingly, or you will silently swap days and months. | --- ## Suppliers ### `GET /api/v2/suppliers.json` **Parameters:** `limit` (default 50, max 250), `since_id` (IDs **greater than**), `updated_since` (ISO 8601). **Fields** | Field | Type | | --- | --- | | `id` | Integer | | `name` | String | | `company_name` | String | | `account_number` | String | | `contact_name` | String | | `contact_email` | String | | `address1` / `address2` | String | | `city` / `province_code` / `country_name` / `zip` | String | | `phone` / `phone_toll_free` / `fax` | String | | `is_hidden` | Boolean | | `created_at` / `updated_at` | String, ISO 8601 | **This contradicts Shopify's own migration guidance.** The help centre says suppliers "can't be exported from Stocky", and every third-party migration guide repeats it, telling merchants to retype their supplier list by hand. That is true of the *interface* — there is no export button. The API returns the full record. **What is genuinely absent:** no `lead_time`, no minimum order quantity, no payment terms, no per-product supplier assignment. Those exist in the Stocky UI but appear nowhere in the API. They have to be captured from the interface. --- ## Stock Adjustment / Stock Adjustment Item `GET /api/v2/stock_adjustments.json` and `GET /api/v2/stock_adjustment_items.json`, each with a `{id}` variant. Field lists were not captured before the deadline; probe with `limit=1` against a live store to see the real shape. Adjustment *reasons* are configured in Stocky (Inventory → Adjustments) and, per merchant reports, do not sync to Shopify — they surface there only as "Stocky". If reasons matter for tax records, verify whether the API returns them and capture them from Reports → Adjustments if not. --- ## Tax Type `GET /api/v2/tax_types.json`, plus `{id}` variant. Needed to resolve `shipping_tax_type` and `purchase_items[].tax_type_id` into actual rates. Small table — fetch it and join locally. --- ## The UI has CSV exports the API does not — and vice versa Verified 8 Aug 2026 on a live store. Both exist: - **Vendors** page → `CSV` button (top right) - **Suppliers** page → `More actions ▾` → export **Shopify's migration guide is wrong on this point.** It states plainly that "Suppliers can't be exported from Stocky", and every third-party migration guide repeats it, telling merchants to retype their supplier list by hand. The export button is there. Both files share the same 17 columns: ``` Name, Address 1, Address 2, City, Province, Zip, Country, Phone, Contact name, Contact email, Website, Payment terms, Payment method, Currency, Account number, Fax number, Notes ``` Observed values: `Payment terms` is an enum-like slug (`net_30`, `pre_paid`), `Payment method` likewise (`credit_card`), `Currency` can be `base` meaning the merchant's own currency. ### Coverage matrix — supplier record | Field | API | UI CSV | | --- | :-: | :-: | | `id` | yes | **no** | | Name | yes | yes | | `company_name` | yes | **no** | | Address 1 / 2, City, Province, Zip, Country | yes | yes | | Phone | yes | yes | | `phone_toll_free` | yes | **no** | | Fax | yes | yes | | Contact name / email | yes | yes | | Account number | yes | yes | | **Website** | **no** | yes | | **Payment terms** | **no** | yes | | **Payment method** | **no** | yes | | **Currency** | **no** | yes | | **Notes** | **no** | yes | | `is_hidden` | yes | **no** | | `created_at` / `updated_at` | yes | **no** | Neither source is complete. A full supplier record requires both, joined — and **the only shared key is the name**, because the CSV carries no id. Count duplicate names before joining and surface collisions to the merchant rather than merging silently; two suppliers called the same thing are not rare in retail. ### Vendors are a separate entity, and lead times live on them Stocky models vendors and suppliers separately. The API exposes suppliers only — there is no vendors endpoint at all. This is why `purchase_orders.supplier_id` is optional: a PO tied to a vendor rather than a supplier record is identified by the `supplier_name` string alone. The vendors CSV has the identical 17-column schema, so it does **not** include lead times. Per the app's own UI, lead times are set per Shopify Vendor or per SKU, under Vendors → All vendors → select a vendor → Settings → Lead & Re-stock Times. That screen is covered by no export. The UI also promises lead times will move "within the supplier entity soon" — that will not happen; the app is switched off on 31 August 2026. Mapping consequence: Shopify recommends `supplier.lead_time_days` as a **variant** metafield, while Stocky holds the value on the vendor. Vendor-level values must be fanned out across every variant carrying that vendor, then SKU-level values written on top so specific settings are not overwritten by general ones. `Re-stock Times` sits on the same screen and has no counterpart in Shopify's recommended schema — it needs a metafield of its own. ### Pagination Confirmed on a live store, 8 Aug 2026, after importing 100 products: `?page=N` works, the page size is **30 rows**, and the nav is Polaris markup in a `.pagination-row` container. The `last` control links directly to the final page, so the total is readable in one request: ```html
2 3 … ``` Read `span.last a[href]` for the page count; fall back to the highest numbered `span.page a[href]`. Do not rely on it alone though — the declared count and the number of pages that actually return rows can differ, and the gap is what a truncated crawl looks like. **The container class is not consistent.** `/products` uses `div.pagination-row`; `/vendors/{id}` uses `div.table-pagination`. Both were observed on the same store the same day. Match either, and fall back to searching the whole document rather than trusting one class name. **Assume every page paginates.** `/vendors/{id}` — the plain detail page — paginates, which is easy to miss because it looks like a detail view rather than a list. Maintaining a list of which paths need paging is how pages get silently truncated; walk them all and let the empty second page cost one request. On a 222-request crawl that cost was 9 wasted probes. **Page size is per-table, not global.** The products list gives 30 rows a page, while a vendor's minimum-order table fitted 100 rows into 2 pages. Do not derive a total from an assumed page size — read the count from the nav and walk it. ### Verified end to end, 8 Aug 2026 100 products were imported under a single vendor and the crawl was run against them. All 100 variants came back from both `/vendors/{id}/edit` (lead-time overrides) and `/vendors/{id}/limits` (minimum orders), stitched across 2 pages each, with 100 distinct variant ids and no duplicates. Titles containing commas, double quotes, apostrophes, em dashes, ampersands and Cyrillic all survived into the output, a 178-character title was not truncated, and a variant with no SKU produced exactly one blank-SKU row without shifting the neighbouring columns — the failure mode worth checking, since SKU is read positionally. Note that 30 is the page size everywhere, which is worth remembering: a list showing exactly 30 rows is at its page limit, not necessarily complete. That coincidence cost some time here — 30 vendors were mistaken for a capped list of more. ### App navigation vs API surface The app's top nav is Vendors, Suppliers, Locations, Purchases, Orders, Inventory, Reports. Of those, only Purchases (partially) and Suppliers are exposed through the API. Locations, Orders, Inventory and Reports are not exposed at all. --- ### Which path reaches what | Data | API key | Browser session | | --- | :-: | :-: | | Purchase orders with line items | **only here** | — | | Supplier `id`, `company_name`, `phone_toll_free`, timestamps | **only here** | — | | Stock adjustments, tax types, stock transfers | yes | via `.csv` reports | | Payment terms, payment method, currency, website, notes | — | **only here** | | 41 variant columns, richer PO report, adjustment reasons and staff | — | **only here** | | Lead times, re-stock periods, per-variant overrides, minimum orders, supplier→vendor links | — | **only here** | Neither column is a superset of the other. A complete extraction needs both. ### Report routes return CSV synchronously — except two Confirmed 8 Aug 2026 against a live store. Report pages carry a CSV button pointing at `/reports/{name}.csv`, and that route returns the file inline with `Content-Type: application/octet-stream`. Three controllers return CSV even without the suffix. | Route | Result | | --- | --- | | `/vendors.csv` | CSV, 17 columns | | `/suppliers.csv` | CSV, 17 columns | | `/reports/variants.csv?sc[]=…` | CSV, all 41 requested columns | | `/reports/purchase_orders.csv` | CSV, 21 columns | | `/reports/stock_adjustments.csv` | CSV, 13 columns | | `/reports/products.csv` | CSV, 3 columns | | `/reports/stock_on_hand.csv` | CSV, 9 columns | | `/reports/profit.csv` | **406** — no CSV representation; export from its page | | `/reports/orders` | **Async.** Queues a job and emails a link that expires in one week | `/reports/orders` is the important exception: a plain GET on it has a side effect. Trigger it deliberately and early, and download the file the same day — a job queued on 30 August may deliver a link to a dead app. **`/reports/purchase_orders.csv` is richer than the API.** Its columns: No., Vendor/Supplier, Shipping Location, Archived, Invoice No., **Supplier Order No.**, PO Date, Ordered, Expected, Paid, Paid On, Payment Due, Shipping Cost, Adjustments, **Tax**, Sub Total, **Sub Total (received only)**, **Total Cost**, Received, **First Item Received**, **Last Item Received**. The bolded ones have no API equivalent. **`/reports/stock_adjustments.csv`** returns No., Location, Product, Variant, SKU, Barcode, Cost, **Reason**, **Notes**, **Employee**, Status, Date, Adjustment. Adjustment reasons were widely reported as unrecoverable; they are not. ### The variants report takes 41 columns via `sc[]` `abc_grade, supplier, vendor, product, variant, sku, barcode, shopify_id, supplier_code, asin, handle, tags, weight, product_type, cost_price, supplier_cost_price, average_cost, price, compare_at_price, full_price, sales_tax, margin, stock_cost, total_retail, qty_sold_3m, qty_sold_12m, on_order, on_draft, stock, shopify_created_at, first_received_at, qty_unfulfilled, sales, gross_sales, qty_received, cancelled, qty_refunded, sales_per_day, depletes_in, inventory_management, position` Also selectable in the UI: `product_image`. Filters are exposed as `q[...]` params, and there is a matching `.pdf` route. **Not available as report columns**, despite appearing in Stocky's own import menu: `min_order`, `min_stock`, `pack_size`, `shelf_limit`, `manual_reorder_point`, `bin_location`, `custom_text_field_1..3`. These must be read off the per-entity pages below. ### Exact field locations for everything else | Value | Where | | --- | --- | | Vendor lead time | `/vendors/{id}/edit` → `vendor[estimated_lead_time]` | | Re-stock period | `/vendors/{id}/edit` → `vendor[restock_for]` | | Per-variant lead-time override | `/vendors/{id}/edit` → `table.lead-time-table`; input `name="lead_time" id="VariantDecorator{variantId}"`. Blank means inherited; the effective value renders in a progress bar whose `title` says "Based on vendor default lead time" | | Minimum order quantity | `/vendors/{id}/limits` and `/suppliers/{id}/limits` → input `name="min_order" id="Variant{variantId}"` (its `data-url` says `update_shelf_limit` — a naming quirk, the field is the minimum order) | | Supplier→vendor assignment | `/suppliers/{id}/edit` → `supplier[assign_products_by]` (`vendor` or `manual`) plus `vendor_checkbox{vendorId}` | The vendor edit page never displays the vendor's name, so names must come from the list pages, including `?hidden=true` for hidden records. ### Features already migrated out of Stocky `/stock_transfers` now 302s to `{shop}.myshopify.com/admin/transfers`. The redirect is cross-origin without CORS, so a browser-side fetch is blocked — but that is informative rather than a problem: transfers have already moved to Shopify, so there is nothing left there to rescue. Expect more routes to follow before the deadline, which is an argument for snapshotting early rather than in the last week. Routes that 404 outright: `/purchases`, `/inventory`, `/reports`, `/settings`. The nav labels do not match the URLs — the real ones are `/purchase_orders`, `/dashboard/*`, `/reports/{name}`, `/preferences/*`. --- ## Known gaps **Custom fields are not exposed, and never will be.** Raised on the Shopify developer forum in December 2024 against the purchase-orders documentation; no nodes for them exist in the schema. Shopify's own Stocky FAQ confirms the design intent: custom fields created in Stocky can be used only within Stocky, and are not available in the Shopify admin either. If a merchant relies on custom fields, that data is API-invisible and dies with the app unless captured from the interface. **Not available anywhere except the interface** (no API, no CSV export): - Supplier lead times and re-stock times (per vendor and per SKU) - Minimum order quantities and case packs - Reorder rules, min/max thresholds, forecasting configuration - ABC analysis - Per-product supplier assignments and supplier-specific SKUs - Custom fields (Stocky-only by design, per Shopify's FAQ) - Adjustment reasons (configured in Stocky; merchants report they do not sync to Shopify, surfacing there only as "Stocky") - Stocktake history (exportable through Stocky's Reports, not the API) **Recovered by the UI CSV rather than the API:** payment terms, payment method, currency, website, notes. These were widely reported as unrecoverable; they are not. Everything in the first list is visible in the Stocky interface. That is the whole argument for a session-based extractor alongside the API one — and it is the part competitors are telling merchants to retype by hand. --- ## Timeline | Date | Event | | --- | --- | | 2 Feb 2026 | Removed from the Shopify App Store. Cannot be reinstalled. | | 31 Aug 2026 | App and API stop working. | | ~29 Nov 2026 | Shopify says read-only access lasts "at least 90 days" after shutdown. Third-party migration vendors claim the data vanishes immediately — that contradicts Shopify's own documentation, and they sell migrations. | Shopify's guidance: historical purchase orders cannot be imported into Shopify, and native CSV upload for purchase orders only adds product line items to a new draft PO.