autumn-plugin-shopify
Build Shopify apps on Autumn. The plugin adds:
- Signed webhooks, routed by topic, with a duplicate guard.
- OAuth install with expiring offline tokens and automatic refresh.
- An Admin GraphQL client that retries throttled calls.
Quickstart
# Cargo.toml
[]
= { = "https://github.com/autumn-foundation/autumn-plugin-shopify" }
= "0.10"
= "1"
= "0.1"
# autumn.toml
[]
= "<client id>"
= "https://app.example.com" # turns OAuth on
= ["read_orders", "write_products"]
use ;
use *;
async
async
async
In the Shopify Dev Dashboard:
- Set the redirect URL to
https://<host>/shopify/auth/callback. - Point webhook subscriptions to
https://<host>/shopify/webhooks. - Declare the compliance topics in
shopify.app.toml(compliance_topics). - Install on a store: open
https://<host>/shopify/auth?shop=<store>.myshopify.com.
See examples/shop_app.rs for a full app.
What the plugin adds
| Piece | Detail |
|---|---|
| Config section | [shopify]. Secrets come only from the environment. |
POST /shopify/webhooks |
Checks the HMAC (401), skips done duplicates (200), answers 409 while the same webhook id still runs, deletes the token on app/uninstalled and shop/redact, runs the topic handler. |
GET /shopify/auth |
Starts the install: state cookie, then a redirect to Shopify. |
GET /shopify/auth/callback |
Checks HMAC, shop, and state; exchanges the code; checks scopes; stores the token; runs the install handler. |
Shopify extractor |
client() for a single store, client_for(&shop) for installed shops, install_url(&shop). |
| Health | A "shopify" indicator in single-store mode, in /actuator/health only (not /ready). |
The three routes are #[public], so autumn routes audit accepts them.
OAuth routes answer 404 when app_url is not set.
Configuration
| Key | Environment variable | Default | Meaning |
|---|---|---|---|
api_key |
SHOPIFY_API_KEY |
none | App client id. OAuth needs it. |
shop |
SHOPIFY_SHOP |
none | The shop of a single-store app. |
api_version |
SHOPIFY_API_VERSION |
2026-10 |
Admin API version. |
scopes |
SHOPIFY_SCOPES (comma-separated) |
[] |
Scopes OAuth asks for. OAuth needs at least one. |
app_url |
SHOPIFY_APP_URL |
none | Public base URL. Setting it turns OAuth on. |
after_install_path |
SHOPIFY_AFTER_INSTALL_PATH |
/ |
Local path after an install. Gets shop=. |
max_throttle_retries |
SHOPIFY_MAX_THROTTLE_RETRIES |
3 |
Retries for a throttled Admin API call (0–10). |
Precedence: ShopifyPlugin::configure > environment > autumn.toml >
defaults. The plugin reads only autumn.toml. It does not apply Autumn
profile layering: use the environment variables for values that change
per profile.
| Secret | Holds |
|---|---|
SHOPIFY_API_SECRET |
Client secret. Required. |
SHOPIFY_API_SECRET_PREVIOUS |
Old client secret, during rotation. Webhook and OAuth HMAC checks accept it. |
SHOPIFY_ACCESS_TOKEN |
Fixed Admin API token (single-store apps). |
A secret key (api_secret, access_token, *_secret, *_token) in
[shopify] stops the startup.
Two app shapes
Single-store (custom) app. Set shop and SHOPIFY_ACCESS_TOKEN. Call
shopify.client() in a route. The startup warns when only one of the two
is set.
Public app. Set app_url, api_key, and scopes. Merchants install
through /shopify/auth. Call shopify.client_for(&shop).await. When the
token expires in 60 seconds or less, client_for refreshes it first. On
NotInstalled or Reauthorize, send the browser to
Shopify::install_url(&shop).
Run code after each install with .on_install(handler). The handler gets
the shop, the granted scopes, and a Shopify handle. Use it to save a shop
record or to call subscribe_webhook.
Calling the Admin API
use Shopify;
use *;
async
query::<T>() deserializes data. subscribe_webhook("orders/create", url)
creates a webhook subscription. Compliance topics are not in the GraphQL
topic list: declare them in shopify.app.toml.
The client retries only throttled calls (THROTTLED or HTTP 429). Shopify
does not run a throttled call, so a retry is safe. A retry of another
failure can run a mutation two times, so the client does not do it. The
client does not follow redirects.
Errors
Call err.to_autumn_error() to get the HTTP status in this table. Do not
use ? to change an Error into an AutumnError: Autumn's blanket From
gives 500.
| Kind | Status |
|---|---|
Signature, NotInstalled, Reauthorize |
401 |
InvalidRequest, Json |
400 |
StateMismatch, ScopeMismatch |
403 |
OAuthDisabled |
404 |
InProgress |
409 |
PayloadTooLarge |
413 |
Http, ShopifyApi, GraphQl, UnexpectedResponse |
502 |
Throttled, MissingRuntime |
503 |
Config, Store |
500 |
Production notes
- Respond fast. Shopify waits 5 seconds. Enqueue a job for slow work.
WebhookContext::stategives theAppState. When a handler takes longer, the Shopify retry gets 409 and comes back later. - Unsigned headers. The webhook HMAC covers the body only. The
X-Shopify-*headers are not signed. Before it deletes a token, the plugin checks that the shop in the body matches the header. Do the same in handlers where a wrong shop can do damage. - Unsigned redirect. The
shopvalue in the redirect after an install is not signed. Do not open a session from it. - Bring a store.
InMemoryTokenStoreandInMemoryDeliveryStorelose data on a restart and do not share data across replicas. ImplementTokenStoreandDeliveryStoreon your database or Redis, and install them with.token_store(...)and.delivery_store(...). - Refresh across replicas. One process runs one refresh per shop at a time. Shopify keeps a presented refresh token usable until the app uses the new one, so a second replica that refreshes at the same time usually gets a pair too. When Shopify refuses a refresh (401), the plugin uses a fresh pair that another replica stored. It deletes the token only when the store still holds the refused pair.
- CSRF. When
security.csrf.enabledis on, add/shopify/webhookstosecurity.csrf.exempt_paths. The plugin logs a warning when the path is missing. - Compliance. Public apps must handle
customers/data_request,customers/redact, andshop/redact. The plugin logs a warning for each missing handler when OAuth is on. - Secret rotation. Put the old secret in
SHOPIFY_API_SECRET_PREVIOUSfor at least one hour. Shopify can sign with the old secret for that long. Token calls use the current secret only. A refresh with a wrong secret gets 403, and the plugin keeps the token.
Testing your handlers
sign_webhook(secret, body)andsign_query(secret, params)make requests the plugin accepts.ShopifyConfig::from_sources(toml, lookup)builds a config without the process environment.ShopifyPlugin::admin_base_url("http://127.0.0.1:<port>")sends Admin API and OAuth calls to a local stub. Only loopback hosts are allowed.WebhookContext::new(...)andInstallContext::new(...)build contexts to call handlers directly. Get theShopifyhandle withShopify::from_state(&state)after you put a runtime fromShopifyPlugin::build_runtimein the state (state.insert_extension).ShopTokenderivesSerializewith both tokens in clear text. Encrypt it at rest in your store, and never log it.
Crate layout
| Module | Purpose |
|---|---|
config |
[shopify] config, SHOPIFY_* variables, builder closures |
types |
ShopDomain, Topic, ApiVersion |
verify |
Webhook and OAuth HMAC checks |
webhooks |
The webhook route, extractor, and topic registry |
delivery |
Duplicate-delivery guard |
oauth |
Install and callback routes, token exchange, refresh |
tokens |
Token storage |
client |
Admin GraphQL client |
health |
Health check |
plugin |
ShopifyPlugin, ShopifyRuntime, Shopify, InstallContext |
error |
Error, ErrorKind, HTTP status mapping |
Not in this release
- Embedded-app session tokens (App Bridge) and token exchange.
- App proxy signature checks.
- Online (per-user) tokens.
- Autumn profile layering for
[shopify].
Docs and comments
ASD-STE100 style: short sentences, active voice, simple present tense.
License
Apache-2.0. See LICENSE.