shopify_example.py
Authenticating
Get credentials: create an app in Shopify’s Dev Dashboard, install it on your store, and copy its client ID and secret — Set up Shopify has every screen. The pack trades them for an Admin API token and trades them again when it runs out, soSHOPIFY_SHOP, SHOPIFY_CLIENT_ID and SHOPIFY_CLIENT_SECRET are all it needs.
This pack takes an OAuth token, sent bare in X-Shopify-Access-Token rather than in Authorization. Its configure() takes the installation as well as the credential, so the shapes it accepts are written out below.
configure() takes the store and one credential, because a Shopify
installation is a store as well as a token. 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 credential is one of three:
configure() is optional when $SHOPIFY_SHOP is set beside either
$SHOPIFY_CLIENT_ID and $SHOPIFY_CLIENT_SECRET or $SHOPIFY_ACCESS_TOKEN.
When both are set the pair wins, because it can always produce a valid token
and a raw one left in the environment usually cannot. That is what keeps
the MCP server working past its first day.
The pair is Shopify’s client credentials grant: the pack posts it to the store’s
own /admin/oauth/access_token, sends the token it gets back in
X-Shopify-Access-Token, and asks again shortly before the token expires. It is
the same OAuth2Client every other OAuth pack
uses, pointed at a token endpoint on the store’s host. Built by hand, which is
what the client below takes as credentials:
shopify_credentials.py
Shopify grants this only when the app and the store belong to the same
organisation, so it is a credential for your own stores. An app installed on
merchants’ stores goes through Shopify’s authorization code flow and holds a
token per store, which is your application’s to keep.
Getting the client ID and secret
Since January 2026 a new app is made in Shopify’s Dev Dashboard rather than in the store admin, and its Admin API token is minted on request rather than shown once on a settings page. Set up Shopify has these steps with a screenshot for each screen:- In the Dev Dashboard, Create app.
- On the app’s version, choose the Admin API access scopes for what the
tools you hand out touch — products, orders, customers, inventory,
locations, draft orders, fulfillments; read for the readers, write for the
rest — and Release it. A call that needs one you left out names it in
the error: “Required access:
read_productsaccess scope”. - Install the app on your store. The app and the store must belong to the same organisation, or Shopify refuses the client credentials grant.
- Copy the Client ID and Client secret from the app’s Settings:
shpat_ token, which still works and does not expire: set it as
$SHOPIFY_ACCESS_TOKEN instead of the pair.
The client
oauth_tool_factory is the whole client: a thin wrapper over httpx that attaches your token and these endpoint constants to each request. ShopifyAPI does not enter your dependency tree.credentials is the provider you built in Authenticating. A pack takes it through configure(); a client you build takes the same object as credential_provider, and has no configure() of its own.
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 aTool, called with
ainvoke as in the snippet above. The name
links to its parameters, its response and what it costs.
shop_getPOSTGet the store’s own details — its name, domain, currency and timezone.products_listPOSTList products.product_getPOSTGet one product with its description and its first 50 variants, including each variant’s SKU, price and inventory.product_createPOSTCreate a product.product_updatePOSTUpdate a product.orders_listPOSTList orders.order_getPOSTGet one order with its line items, shipping address and totals.customers_listPOSTList customers.customer_getPOSTGet one customer, with lifetime spend and default address.customer_createPOSTCreate a customer.customer_updatePOSTUpdate a customer.order_fulfillment_ordersPOSTList an order’s fulfillment orders, which is the first half of fulfilling it.fulfillment_createPOSTFulfil one or more fulfillment orders, marking the goods as shipped.inventory_adjust_quantitiesPOSTMove stock by a relative amount:
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. delta of 5 adds five, -5 removes five.variant_inventory_levelPOSTRead a variant’s stock at one location.locations_listPOSTList the store’s locations.order_closePOSTClose an order, marking it finished.order_openPOSTReopen a closed order.order_cancelPOSTCancel an order.draft_order_createPOSTCreate a draft order, the way to build an order by hand before it is placed.draft_order_completePOSTTurn a draft order into a real one.product_variants_bulk_createPOSTAdd variants to a product.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 athttps://{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
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’serrors 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
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
Ids are global, and they are not numbers
Ids are global, and they are not numbers
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.Filtering is an untyped search string
Filtering is an untyped search string
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.Orders stop 60 days back unless you were granted a scope
Orders stop 60 days back unless you were granted a scope
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.Rate limiting is a cost budget, not a request count
Rate limiting is a cost budget, not a request count
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.A new product is not on the storefront
A new product is not on the storefront
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.A customer needs an email or a phone number
A customer needs an email or a phone number
customer_create with neither is refused — and refused through
userErrors, at HTTP 200, which is exactly the case the envelope exists for.Related
- Linear — the other GraphQL pack
- Envelopes — several error paths in one declaration
- The wire contract — a base URL that is not a constant