Skip to main content
22 toolsAPI key
shopify_example.py
Products, orders, customers and the shop itself — the read-and-triage core of the Admin API. Fulfillment, inventory adjustment, discounts and the Storefront API are not modelled, and neither are bulk operations: Shopify’s answer to a large export is an asynchronous job you poll and then download, and that is orchestration. Shopify is the second GraphQL pack. Read Linear first for what the two have in common; this page is about what Shopify breaks that Linear does not.

Authenticating

This pack takes an API key in X-Shopify-Access-Token. There is no authorization server, no consent screen and no refresh — API keys is the whole story. Two values, because a Shopify installation is a store as well as a token: configure() is optional when both $SHOPIFY_SHOP and $SHOPIFY_ACCESS_TOKEN are set. shop is the subdomain — "my-store" for my-store.myshopify.com — and the full domain or a complete URL is accepted too, since all three appear in Shopify’s own documentation and admin UI.

The client

api_key_tool_factory is the whole client: a thin wrapper over httpx that attaches your key and these endpoint constants to each request. ShopifyAPI does not enter your dependency tree.

Paging through a list

A cursor belongs to the tool that returns it, so it is declared on that tool’s builder call:
shopify_pagination.py
Pagination is declared on products_list, orders_list, customers_list and locations_list. The other 18 take no cursor.

Tools

Each is a Tool, called with ainvoke as in the snippet above. The name links to its parameters, its response and what it costs. The API version sits in the path, and pinning it is not optional: Shopify ships a new version quarterly and supports each for twelve months, so an unpinned integration is one that changes under you. shopify.API_VERSION is the version these documents were written against.

A host that is not a constant

Every other pack here has a fixed base URL. A Shopify store lives at https://{shop}.myshopify.com/, so the host is only known once someone installs the app. base_url therefore takes a callable, resolved on every request:
shopify_resolve_base_url.py
The host must never be a schema field. A Path() parameter for the subdomain would let the model choose which server the request goes to, which is the one thing a boundary cannot permit. An unresolved base URL raises CredentialError before anything leaves the process, the same way an unconfigured credential does. Zendesk, Atlassian and self-hosted GitLab have the same shape. See the wire contract.

Failure hides in userErrors

As with Linear, GraphQL’s errors array only carries problems with the document. A mutation Shopify ran and then refused — a duplicate handle, a customer with no contact details — returns HTTP 200, no errors, and a populated userErrors:
shopify_user_errors.json
errors_field takes several paths, so one declaration covers both places:
shopify_envelope.py
A query has no userErrors, and a successful mutation has an empty one, so neither matches. The error message names the resolved path, so it says which write was declined without the envelope knowing anything about Shopify.

Money comes back as a MoneyBag

Shopify returns every price as {"shopMoney": {"amount": "10.00", "currencyCode": "USD"}}, because a store can present one currency and settle in another. Two levels of nesting per price, on every line item of every order, is a lot of envelope for a number. Every tool here runs an unwrap handler that strips GraphQL’s data and operation name and flattens the shop-currency side to "10.00 USD". The walk covers the whole payload, since prices appear on the order, on each line item, and on a customer’s lifetime spend.

Gotchas

product_get, order_get and customer_get take a global id — gid://shopify/Product/123456 — not the bare number from the admin URL. The list tools return ids in that form, which is the intended way to get one.
Shopify’s query argument takes its own syntax rather than a filter object, so it cannot be typed the way Linear’s IssueFilter can. The supported qualifiers are documented on each field instead: financial_status:paid fulfillment_status:unshipped for orders waiting to ship, status:active vendor:Acme for products, orders_count:>5 for customers.
Shopify serves an app holding read_orders only the last 60 days of a store’s orders. Everything older needs read_all_orders, which Shopify grants by review rather than on request.The truncation is silent — no error, no marker on the page, just a shorter history than the store has — so orders_list says so in the description the model reads, and a created_at: window reaching further back is called out on the query field. An empty result is not evidence of an empty history.
Shopify prices each query by the fields it selects, so no constant quota_cost would be truthful — the cost of a call depends on the page size it is invoked with. The selection sets in charter.packs.shopify.queries are deliberately narrow for the same reason, and keeping first small is worth doing.
Shopify creates products unpublished. product_create returns successfully and the product is invisible to shoppers until it is published to a sales channel, which is a separate operation and is not modelled here.
customer_create with neither is refused — and refused through userErrors, at HTTP 200, which is exactly the case the envelope exists for.
product_update overwrites rather than merges, the same as Linear’s label_ids and GitHub’s labels. Read, union, send.