autumn-plugin-shopify 0.3.0

Autumn plugin: Shopify apps — signed webhooks routed by topic, OAuth install with expiring tokens, and a throttle-aware Admin GraphQL client.
Documentation

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
[dependencies]
autumn-plugin-shopify = { git = "https://github.com/autumn-foundation/autumn-plugin-shopify" }
autumn-web = "0.10"
serde_json = "1"
tracing = "0.1"
# autumn.toml
[shopify]
api_key = "<client id>"
app_url = "https://app.example.com"     # turns OAuth on
scopes = ["read_orders", "write_products"]
export SHOPIFY_API_SECRET="<client secret>"
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

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 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.

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::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

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.