Skip to main content
A merchant connects a store, and the store is part of the connection: the API host, the consent page and the token endpoint all sit on it. So the store is named per call as well as the token. shop= takes a function for exactly this, and it is called on every request. The app is one Dev Dashboard app for every store: Register a Shopify app. Ask for an expiring offline token. Shopify requires public apps to use one for the GraphQL Admin API, which is what the pack calls, by January 1, 2027. It lasts an hour and comes with a refresh token that lasts 90 days, and both are replaced on every refresh. exchange_params={"expiring": "1"} on the server is what asks for it.

The server

shopify_server.py
A function rather than a constant, because every endpoint is on the merchant’s store. scope_separator is the comma Shopify documents. dead_grant_errors is how Shopify says a grant is gone: Refresh behaviour. revocation uninstalls the app: Disconnecting.

Scopes

Each tool declares the access scopes it needs, from the “Requires … access scope” line on every object and mutation it touches in the Admin GraphQL reference. “Any scope that writes a resource also grants read access to it”, so each reader also accepts the write scope:
shopify_scopes.py
On Shopify the scopes are set on the app’s released version rather than taken from the install link, so SHOPIFY_SCOPES is the list to type under Access when you register the app. Two classes of resource sit behind their own scopes, and no tool asks for them. Orders older than 60 days need read_all_orders beside read_orders, and Shopify approves that scope per app; without it the order tools see the last 60 days, with no error for the rest. Fulfillment orders at fulfillment-service, third-party or marketplace locations need their own *_fulfillment_orders scopes; the tools ask for the merchant-managed ones, which is where a store’s own app works. An app that needs either derives the tools with scopes=[...]. A field the token’s scopes do not reach comes back as 200 with an ACCESS_DENIED error naming them (“Required access: read_orders access scope.”), and raises InsufficientScope with those scopes. A public app’s access to protected customer data is approved in the Partner Dashboard rather than granted by a scope: customer data at all, and a customer’s name, address, phone and email on top. Until it is, a field the app is not approved for comes back null in a 200, beside an errors entry (“This app is not approved to access the Customer object”), which the pack’s envelope raises as an APIError. A custom app has both levels without review, unless a merchant created it in the admin, where level 2 depends on the plan.

Connecting a merchant

shopify_users.py
The pack sends the token in X-Shopify-Access-Token, wherever it came from.

Refresh behaviour

Shopify refuses a refresh token it no longer honours, the grant’s after the app was uninstalled or one it never issued, with invalid_request (“This request requires an active refresh_token”) in a 401, measured, where RFC 6749 says invalid_grant. dead_grant_errors declares it, so once a merchant uninstalls your app their tool calls stop asking Shopify again and the error’s reauthorize is true. As with Linear, the same code would answer a missing parameter, which a refresh from Charter never has.

A non-expiring token

A store that holds a non-expiring offline token, issued before expiring tokens or to a custom app, keeps working as StaticTokenProvider(token), exchanged with expect_refresh_token=False. Pairing a per-call store with a single access_token raises: one store’s token would go to every other store.

Disconnecting

The SubjectProvider the pack is configured with builds the merchant’s client from your store and revokes the grant, in one call:
shopify_disconnect.py
Shopify has no revocation endpoint. Its documented way to end an installation’s access is to uninstall the app with the store’s own token, DELETE /admin/api_permissions/current.json, so that is the declaration. The merchant can install it again from your link. Measured, the access token was refused at once and the refresh token with it. Uninstalling ends every grant your app holds on that store, not only this one: the app’s client credentials grant on the store answered app_not_installed until the app was installed again. A store already uninstalled answers 401, which counts as gone, and revoke() returns False.

Where the rest is