django-shopify-app
A reusable Django package for building Shopify apps. Handles OAuth, token exchange, webhook management, session token validation (JWT), and Shopify API interactions.
Installation
pip install django-shopify-app
Supports Python 3.10+ and Django 5.2 LTS through 6.1. The floor tracks the oldest Django branch that still receives security fixes, so expect it to move when a branch reaches end of life.
Add the app in settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'shopify_app',
'shops',
]
Settings
# Required
SHOPIFY_API_KEY = config('SHOPIFY_API_KEY')
SHOPIFY_API_SECRET = config('SHOPIFY_API_SECRET')
SHOPIFY_APP_HOST = 'https://your-app.com'
SHOPIFY_SHOP_MODEL = 'shops.Shop' # Must inherit from ShopBase
SHOPIFY_WEBHOOK_CALLBACK = 'shops.webhooks.webhook_entry'
SHOPIFY_GDPR_WEBHOOK_CALLBACK = 'shops.webhooks.gdpr_webhook_entry'
SHOPIFY_TOKEN_DB_ALIAS = 'shopify_tokens' # DATABASES alias for token writes; see
# "Token database connection" below
# Optional
SHOPIFY_APP_SCOPES = ['read_products', 'read_orders']
SHOPIFY_WEBHOOK_TOPICS = ['products/update', 'app/uninstalled']
SHOPIFY_WEBHOOK_HOST = 'https://your-app.com' # Defaults to SHOPIFY_APP_HOST
SHOPIFY_API_VERSION = '2026-07' # Pin this; see note below
# Token exchange (recommended for embedded apps)
SHOPIFY_TOKEN_EXCHANGE = False # Default: False
SHOPIFY_DASHBOARD_PATH = '/dashboard' # Default: '/dashboard'
# Expiring offline access tokens (Shopify, Dec 2025)
SHOPIFY_EXPIRING_TOKENS = False # Default: False — opt into expiring
# offline tokens + refresh rotation
SHOPIFY_TOKEN_REFRESH_SKEW = 300 # Default: 300s — refresh this many
# seconds before access-token expiry
SHOPIFY_HTTP_TIMEOUT = (5, 30) # Default: (connect, read). Applies to ALL
# outbound Shopify calls. Read is generous so
# a processed refresh isn't abandoned.
SHOPIFY_TOKEN_ENCRYPTION_KEY = config('SHOPIFY_TOKEN_ENCRYPTION_KEY')
# Fernet key encrypting refresh_token at
# rest. Required once a refresh token is
# stored (i.e. when expiring tokens are on).
# May be a list of keys for rotation.
# Generate: python -c "from cryptography.fernet
# import Fernet; print(Fernet.generate_key().decode())"
# Per-shop credential overrides (replaces DB overwrite fields)
SHOPIFY_CREDENTIALS_OVERRIDES = { # Default: {}
'shop.myshopify.com': {
'api_key': '...',
'api_secret': '...',
},
}
# Staff bypass
SHOPIFY_STAFF_BYPASS = False # Default: False
SHOPIFY_STAFF_BYPASS_METHODS = None # Default: None (all methods)
SHOPIFY_STAFF_SHOP_ATTR = 'admin_shop' # Default: 'admin_shop'
API version. Shopify supports each stable API version for 12 months. Set SHOPIFY_API_VERSION explicitly and bump it on a schedule; the package default (ShopifyAppConfig.DEFAULT_API_VERSION) is only updated with package releases. The shopify_app.W001 system check, run by manage.py check, runserver and migrate, warns when the configured version has reached end of life.
ShopBase model
Your app must define a Shop model that inherits from ShopBase:
from shopify_app.models import ShopBase
class Shop(ShopBase):
# Add your custom fields
plan_name = models.CharField(max_length=50, default='')
def installed(self, request=None):
"""Called when the app is installed on a shop for the first time."""
self.update_webhooks()
self.crm_on_install()
def token_reacquired(self, request=None):
"""Called instead of installed() when a shop whose rejected token was cleared
gets a new one: the store reopened, or the app was reinstalled after a lost
app/uninstalled webhook. Defaults to installed(); override it to skip install
work that shouldn't run twice."""
self.update_webhooks()
def on_user_login(self, user_data, request=None):
"""Called when a Shopify user opens the app.
user_data contains: id, first_name, last_name, email,
email_verified, account_owner, locale, collaborator
"""
self.crm_on_login(user_data)
Fields
| Field | Type | Description |
|---|---|---|
shopify_domain |
CharField | The shop's .myshopify.com domain |
shopify_token |
CharField | Offline access token |
access_scopes |
CharField | Granted OAuth scopes |
seen_users |
JSONField | Tracks user logins {user_id: {last_login: ...}} |
refresh_token |
EncryptedCharField | Refresh token for expiring offline tokens (encrypted at rest). Empty unless SHOPIFY_EXPIRING_TOKENS is on |
token_expires_at |
DateTimeField | When the offline access token expires (expiring tokens only) |
refresh_token_expires_at |
DateTimeField | When the refresh token expires — 90-day window (expiring tokens only) |
refresh_token_rejected_at |
DateTimeField | When Shopify first rejected the shop's stored token: the refresh token (dead token or app uninstalled; the token is kept), or a permanent token it refused to migrate (the token is cleared). Cleared when a new token is stored. Read it through shop.reauth_required |
Note:
refresh_token,token_expires_atandrefresh_token_expires_atwere added in 2.4.0,refresh_token_rejected_atin 2.7.0. BecauseShopBaseis abstract, runmakemigrationsin your app to add the columns to your concrete Shop model.
API methods
All REST methods replace api_version in the path automatically:
# REST API
shop.get('/admin/api/api_version/products.json')
shop.post('/admin/api/api_version/products.json', data={...})
shop.put('/admin/api/api_version/products/123.json', data={...})
shop.patch('/admin/api/api_version/products/123.json', data={...})
shop.delete_request('/admin/api/api_version/products/123.json')
# GraphQL
response = shop.graphql(query='{ shop { name } }')
response = shop.graphql(query=mutation, variables={...}, api_version='2025-04')
# GraphQL via shopify library (uses session context manager)
result = shop.graph(operation_name='GetShop', variables={}, operations_document=query)
Webhook management
shop.update_webhooks() # Deactivate all + reactivate from SHOPIFY_WEBHOOK_TOPICS
shop.activate_webhooks() # Alias for update_webhooks()
shop.deactivate_webhooks() # Remove all registered webhooks
CRM events
Built-in hooks for CRM lifecycle events (via django-crm-events):
shop.crm_on_install()
shop.crm_on_uninstall(users=[])
shop.crm_on_login(user_data)
shop.crm_on_billing_plan_change(plan_price)
Other properties
shop.shopify_app_api_key # Returns per-shop override or global setting
shop.shopify_app_api_secret # Returns per-shop override or global setting
shop.api_version # From SHOPIFY_API_VERSION setting
shop.host # Base64-encoded admin URL for App Bridge
shop.get_shop_data() # Fetch shop details from Shopify REST API
shop.valid_access_token() # Access token, auto-rotated if expiring + stale
shop.reauth_required # True when Shopify rejected the refresh token (see below)
All REST/GraphQL helper methods send valid_access_token(). With SHOPIFY_EXPIRING_TOKENS enabled, that proactively rotates a near-expiry token, and the requests-based helpers (get/post/put/patch/delete_request/graphql) additionally refresh-and-retry once on a 401. With the flag off, the token is returned as-is.
HMAC validation
shop.app_proxy_request_is_valid(request) # Validate app proxy HMAC
shop.request_shopify_hmac_is_valid(request) # Validate standard Shopify HMAC
Authorization
The package supports two authorization flows: token exchange (recommended for embedded apps) and authorization code grant (legacy / non-embedded apps).
Token exchange (recommended)
Token exchange eliminates OAuth redirects. The backend exchanges the session token from App Bridge for an access token via a server-side POST to Shopify. No page reloads or flicker.
Scopes are managed via shopify.app.toml and deployed with Shopify CLI (shopify app deploy). Shopify handles installation and scope updates automatically.
Add to settings.py:
SHOPIFY_TOKEN_EXCHANGE = True # Enable token exchange
SHOPIFY_DASHBOARD_PATH = '/dashboard' # Where to redirect from app root
Set up your URLs:
from django.urls import path, include
from shopify_app.views import AppRootView
urlpatterns = [
path('', AppRootView.as_view()),
path('shopify/', include('shopify_app.urls')),
# your dashboard urls...
]
When a merchant opens your app, AppRootView redirects to the dashboard, forwarding only the shop, host, locale and embedded query parameters. The id_token, hmac, signature, session and timestamp values Shopify appends to the app URL are dropped so they never appear in the dashboard URL or in access logs. The first API request from the dashboard triggers token exchange automatically via ShopSessionMixin / shop_session, storing the access token for subsequent requests.
User login tracking is handled automatically: on the first request from a new user (or after 24 hours), an online token exchange fetches user details and fires shop.on_user_login().
Authorization code grant (legacy)
For non-embedded apps or apps that don't use Shopify managed installation.
from django.urls import path
from shopify_app.views import AppRootView, EndTokenRequestView
app_name = 'my_shopify_app'
urlpatterns = [
path(
'',
AppRootView.as_view(
redirect_path_name='my_shopify_app:end-token-request',
),
),
path(
'confirm/',
EndTokenRequestView.as_view(
redirect_path_name='embed_admin:dashboard',
),
name='end-token-request'
),
]
With SHOPIFY_TOKEN_EXCHANGE = False (default), AppRootView falls back to the OAuth authorization code grant flow. The shop query parameter is validated as a *.myshopify.com domain before the OAuth URL is built; anything else gets a 400. If you override the shopify_app/index.html template, keep the redirect value escaped with escapejs.
Expiring offline access tokens
As of December 2025 Shopify supports expiring offline access tokens (60-minute access token + a 90-day refresh token), and requires them for new public apps from April 1, 2026. Enable them with:
SHOPIFY_EXPIRING_TOKENS = True
SHOPIFY_TOKEN_ENCRYPTION_KEY = config('SHOPIFY_TOKEN_ENCRYPTION_KEY') # required
SHOPIFY_TOKEN_REFRESH_SKEW = 300 # optional, seconds before expiry to refresh
When enabled:
- Acquisition — token exchange and the authorization code grant request
expiring=1, so the response includes arefresh_token. Tokens are stored viarefresh_token/token_expires_at/refresh_token_expires_at. - Rotation —
valid_access_token()refreshes a token withinSHOPIFY_TOKEN_REFRESH_SKEWseconds of expiry; REST/GraphQL helpers also refresh-and-retry once on a401. Refresh is concurrency-safe (select_for_update), so parallel workers never double-spend the one-time-use refresh token. A failed refresh raises; see "Refresh failures" below. - Migration — a legacy non-expiring token is replaced by an expiring one the next time a merchant opens the app (the session token is exchanged; the permanent token is never sent), or in bulk:
python manage.py migrate_offline_tokens # migrate all non-expiring shops
python manage.py migrate_offline_tokens --shop example.myshopify.com
python manage.py migrate_offline_tokens --dry-run # preview only
Migration is irreversible — Shopify revokes the original non-expiring token on success.
When Shopify rejects the stored token (400 invalid_subject_token) or the store is unavailable (404 closed or deleted, 402 frozen, 423 locked), the bulk migration clears the token and sets refresh_token_rejected_at, logging a WARNING. A permanent token is never kept for a store that might reopen: from January 1, 2027, Shopify delists public apps that still use them. The cleared shop drops out of later runs and gets a new token when a merchant opens the app again, which calls shop.token_reacquired() instead of installed(). Other failures (429, 5xx, credential errors) keep the token and log an ERROR.
With SHOPIFY_EXPIRING_TOKENS = False (default), behaviour is unchanged: non-expiring offline tokens, no refresh, no encryption key required.
Refresh failures
A failed refresh raises one exception per response type, all in shopify_app.exceptions. Nothing is logged alongside them; the exception message carries the shop domain, status code and response body. Because valid_access_token() raises, the API helpers (get/post/…/graphql/graph) raise before sending a request with an expired token. The 401 retry raises the same way.
| Response from Shopify's token endpoint | Exception | What it means |
|---|---|---|
| no refresh token stored | RefreshTokenMissing |
Nothing to refresh with |
401 {"error": "invalid_request"} |
RefreshTokenInvalid |
The refresh token is dead: expired, replaced, revoked, or the app was uninstalled. Shopify doesn't say which. Sets refresh_token_rejected_at |
404 (any body) |
ShopNotFound |
Usually a closed or deleted store |
429 |
TokenRefreshRateLimited |
Rate limited |
5xx |
TokenRefreshUnavailable |
Shopify is unavailable; safe to retry later. Also a requests.RequestException, so autoretry_for=RequestException retries it |
| any other non-200 | UnexpectedTokenRefreshResponse |
e.g. a 401 with a different error, a 400 or a 403 |
| shop row stayed locked (Postgres, 10 s) | TokenRefreshLockTimeout |
The calling transaction most likely holds the shop row; see "Token database connection" |
- Shared base class. All of them subclass
TokenRefreshError, which subclassesShopifyRequestException, so existingexcept ShopifyRequestExceptionhandlers keep working. Timeouts and connection errors still raise therequestsexceptions. shop.reauth_requiredisTrueonce Shopify has rejected the shop's refresh token. Refreshes are still attempted on later calls. The flag is cleared when a new token is stored: the merchant opening the app (token exchange) or reinstalling. Use it to find shops that need the merchant to open the app.
Token database connection
Shopify rotates the token as soon as it answers a refresh or token exchange. If the new pair were saved inside your transaction.atomic() block and the block rolled back (say, the GraphQL call that follows fails), it would be lost from your database. So token refreshes, self-heals and migrations lock the shop row and save the new tokens through their own database connection, named by SHOPIFY_TOKEN_DB_ALIAS. That connection commits on its own, whatever your transaction does. It's required from 2.8.0 (system check shopify_app.E001):
# settings.py
DATABASES["shopify_tokens"] = {
**DATABASES["default"],
"CONN_MAX_AGE": 0,
"TEST": {"MIRROR": "default"}, # no separate test database
}
SHOPIFY_TOKEN_DB_ALIAS = "shopify_tokens"
# test settings
SHOPIFY_TOKEN_DB_ALIAS = "default"
- Connections: the package opens the alias connection only for a token write and closes it right after. Workers that aren't refreshing hold no extra connection, and at most one is open per refresh in flight.
- Your shop object stays on its own database. The new token values are copied onto it, and your later
shop.save()calls stay in your transaction. - Tests: a separate connection can't see rows your tests create inside their transaction, so point token writes back at
defaultin test settings."default"means token writes share the caller's transaction (the behaviour before 2.8.0). - Don't save or lock the shop and then call Shopify in the same transaction. The token connection would wait for a row lock that your transaction holds until it ends. On Postgres it gives up after 10 seconds with
TokenRefreshLockTimeoutinstead of hanging. Save the shop after the API call, or in a separate transaction. - First install and the OAuth code grant still save on
default: they run in views, outside any transaction.
Webhook URLs
Include the package URLs for webhook and GDPR endpoints:
from django.urls import path, include
urlpatterns = [
path('shopify/', include('shopify_app.urls')),
]
This registers:
shopify/webhooks— Main webhook receivershopify/gdpr-webhooks/customer-data-request— GDPR customer data requestshopify/gdpr-webhooks/customer-data-erasure— GDPR customer data erasureshopify/gdpr-webhooks/shop-data-erasure— GDPR shop data erasure
ShopSessionMixin
A mixin that authenticates requests against a valid Shopify shop session (JWT). Use it with any APIView or DRF generic view:
from rest_framework.views import APIView
from shopify_app.mixins import ShopSessionMixin
class MyView(ShopSessionMixin, APIView):
def get(self, request, *args, **kwargs):
shop = request.shop
...
Staff bypass
Staff users can skip Shopify JWT validation if they have a shop associated with their user model. Enable it globally in settings:
SHOPIFY_STAFF_BYPASS = True # Default: False
SHOPIFY_STAFF_BYPASS_METHODS = ['GET', 'HEAD', 'OPTIONS'] # Default: None (all methods)
SHOPIFY_STAFF_SHOP_ATTR = 'admin_shop' # Default: 'admin_shop'
SHOPIFY_STAFF_BYPASS_METHODS restricts which HTTP methods are allowed through the bypass. When None (default), all methods are allowed. When set, unlisted methods (e.g. POST, PUT, DELETE) will require Shopify JWT validation even for staff users.
Or per-view:
class MyView(ShopSessionMixin, APIView):
allow_staff_bypass = True # Overrides the global setting
When enabled, if the request user is authenticated, is staff, and has a truthy value on the configured attribute (admin_shop by default), the mixin sets request.shop from that attribute and skips JWT validation.
Decorators
@shop_session
Validates the Shopify session token (JWT) from the request. Injects shop, shopify_domain, and shopify_user_id into the view kwargs:
from shopify_app.decorators import shop_session
@shop_session
def my_view(request, *args, **kwargs):
shop = kwargs['shop']
user_id = kwargs['shopify_user_id']
The token is read from, in order: the Authorization: Bearer <token> header (preferred), the ShopifyAuth: Bearer <token> header (deprecated, kept for older frontends), or the id_token query parameter that App Bridge adds to the first page load. New frontends should send the standard Authorization header.
Error tracking
Session tokens are short-lived but replayable, so keep them out of your error tracker. Sentry and similar tools scrub Authorization and Cookie headers by default but capture custom headers and raw query strings verbatim. If you still send ShopifyAuth, or you enable send_default_pii, add a scrubber for the ShopifyAuth header and the id_token query parameter. With sentry_sdk:
def _strip_session_tokens(event, hint):
request = event.get("request") or {}
request.get("headers", {}).pop("ShopifyAuth", None)
if "id_token=" in request.get("query_string", ""):
request["query_string"] = "[Filtered]"
return event
sentry_sdk.init(..., send_default_pii=False, before_send=_strip_session_tokens)
@shopify_embed
Adds Content-Security-Policy frame-ancestors header for embedded app views:
from shopify_app.decorators import shopify_embed
@shopify_embed
def my_view(request, **kwargs):
...
@known_shop_required
Validates that a shop query parameter is present and the shop exists in the database. Returns 401 if invalid:
from shopify_app.decorators import known_shop_required
@known_shop_required
def my_view(request, *args, **kwargs):
shop = kwargs['shop']
@app_proxy_view
Validates HMAC for Shopify app proxy requests. Fetches the shop from the database:
from shopify_app.decorators import app_proxy_view
@app_proxy_view
def my_proxy_view(request, *args, **kwargs):
shop = kwargs['shop'] # Shop model instance (from DB)
domain = kwargs['shopify_domain'] # e.g. 'example.myshopify.com'
customer_id = kwargs['logged_in_customer_id'] # Customer ID or None
@app_proxy_view_lite
Validates HMAC without hitting the database. Use this when you don't need the full shop record:
from shopify_app.decorators import app_proxy_view_lite
@app_proxy_view_lite
def my_proxy_view(request, *args, **kwargs):
domain = kwargs['shopify_domain'] # e.g. 'example.myshopify.com'
customer_id = kwargs['logged_in_customer_id'] # Customer ID or None
# No kwargs['shop'] — use get_shop() if you need the DB record
@latest_access_scopes_required
Checks if the shop's stored scopes match the configured scopes. Sets scope_changes_required=True in kwargs if they differ:
from shopify_app.decorators import latest_access_scopes_required
@shop_session
@latest_access_scopes_required
def my_view(request, *args, **kwargs):
if kwargs.get('scope_changes_required'):
# handle scope update
Utilities
from shopify_app.utils import (
get_shop_model, # Resolve shop model from SHOPIFY_SHOP_MODEL setting
get_shop, # Get shop by domain (raises DoesNotExist)
get_auth_shop, # Get shop or unsaved instance for auth lookup
webhook_request_is_valid, # Validate webhook HMAC
liquid_render, # Render template with Liquid content-type
app_proxy_url_builder, # Build full URL for app proxy requests
app_proxy_redirect, # Redirect to app proxy URL
)
Management commands
# Activate webhooks for a shop by subdomain
python manage.py activate_shop_webhooks <shopify_subdomain>
# e.g. activate_shop_webhooks example-store → targets example-store.myshopify.com
# Migrate shops from non-expiring to expiring offline tokens (irreversible).
# Tokens Shopify rejects (closed, deleted or uninstalled stores) are cleared.
python manage.py migrate_offline_tokens [--shop <domain>] [--dry-run]
Metadata
Release files for django-shopify-app 2.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_shopify_app-2.9.0.tar.gz | 46.8 kB | Without provenance | Details for django_shopify_app-2.9.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_shopify_app-2.9.0-py3-none-any.whl | Python 3 | none | any | Details for django_shopify_app-2.9.0-py3-none-any.whl |
Total release size: 86.3 kB
Release files / django_shopify_app-2.9.0.tar.gz
| Download URL | django_shopify_app-2.9.0.tar.gz |
|---|---|
| Size | 46.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
def8ff7db094448aaa69384f526e583059f4025aa7e7c4b46fb190a3ca1dc6ad
|
|
BLAKE2b-256 checksum How to use checksums |
5125454bf5a8e92d54eb3e77542801c0c5399a9fd49a3fa5f24acedbc00f57ed
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.1
|
Release files / django_shopify_app-2.9.0-py3-none-any.whl
| Download URL | django_shopify_app-2.9.0-py3-none-any.whl |
|---|---|
| Size | 39.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c69c9d0507d9975ee49d1be3b51bc41ff4bda5ab281d474ea2153e425b966b26
|
|
BLAKE2b-256 checksum How to use checksums |
6c909aaba0c8ee04de3f2120dffb6217e0f0a1e8a7f1d8627b30dfafee63284f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.1
|
Security for django-shopify-app 2.9.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.