# autumn-plugin-shopify
Build Shopify apps on [Autumn](https://autumn-web.app). 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
```toml
# Cargo.toml
[dependencies]
autumn-plugin-shopify = { git = "https://github.com/autumn-foundation/autumn-plugin-shopify" }
autumn-web = "0.10"
serde_json = "1"
tracing = "0.1"
```
```toml
# autumn.toml
[shopify]
api_key = "<client id>"
app_url = "https://app.example.com" # turns OAuth on
scopes = ["read_orders", "write_products"]
```
```sh
export SHOPIFY_API_SECRET="<client secret>"
```
```rust
use autumn_plugin_shopify::{ShopifyPlugin, Topic, WebhookContext};
use autumn_web::prelude::*;
async fn order_created(ctx: WebhookContext) -> AutumnResult<()> {
let order: serde_json::Value = ctx.json().map_err(|e| e.to_autumn_error())?;
tracing::info!(shop = %ctx.shop, order = %order["id"], "order created");
Ok(())
}
async fn compliance(ctx: WebhookContext) -> AutumnResult<()> {
tracing::info!(shop = %ctx.shop, topic = %ctx.topic, "compliance request");
Ok(())
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.plugin(
ShopifyPlugin::new()
.webhook("orders/create", order_created)
.webhook(Topic::CUSTOMERS_DATA_REQUEST, compliance)
.webhook(Topic::CUSTOMERS_REDACT, compliance)
.webhook(Topic::SHOP_REDACT, compliance),
)
.run()
.await;
}
```
In the Shopify Dev Dashboard:
1. Set the redirect URL to `https://<host>/shopify/auth/callback`.
2. Point webhook subscriptions to `https://<host>/shopify/webhooks`.
3. Declare the compliance topics in `shopify.app.toml` (`compliance_topics`).
4. 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
| 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
| `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.
| `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
```rust
use autumn_plugin_shopify::Shopify;
use autumn_web::prelude::*;
#[get("/orders/count")]
#[secured]
async fn order_count(shopify: Shopify) -> AutumnResult<String> {
let client = shopify.client().map_err(|e| e.to_autumn_error())?;
let data = client
.graphql("query { ordersCount { count } }", serde_json::json!({}))
.await
.map_err(|e| e.to_autumn_error())?;
Ok(data["ordersCount"]["count"].to_string())
}
```
`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.
| `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::state` gives the `AppState`. 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 `shop` value in the redirect after an install
is not signed. Do not open a session from it.
- **Bring a store.** `InMemoryTokenStore` and `InMemoryDeliveryStore` lose
data on a restart and do not share data across replicas. Implement
`TokenStore` and `DeliveryStore` on 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.enabled` is on, add `/shopify/webhooks` to
`security.csrf.exempt_paths`. The plugin logs a warning when the path is
missing.
- **Compliance.** Public apps must handle `customers/data_request`,
`customers/redact`, and `shop/redact`. The plugin logs a warning for each
missing handler when OAuth is on.
- **Secret rotation.** Put the old secret in `SHOPIFY_API_SECRET_PREVIOUS`
for 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)` and `sign_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(...)` and `InstallContext::new(...)` build contexts
to call handlers directly. Get the `Shopify` handle with
`Shopify::from_state(&state)` after you put a runtime from
`ShopifyPlugin::build_runtime` in the state (`state.insert_extension`).
- `ShopToken` derives `Serialize` with both tokens in clear text. Encrypt
it at rest in your store, and never log it.
## Crate layout
| `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`.