Skip to main content

A django app with all the tools required to make a Shopify app

Project description

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

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'

# 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 = '2025-04'                # Defaults to '2022-04'

# 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_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'

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

Note: These three fields were added in 2.4.0. Because ShopBase is abstract, run makemigrations in 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

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

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 a refresh_token. Tokens are stored via refresh_token / token_expires_at / refresh_token_expires_at.
  • Rotationvalid_access_token() refreshes a token within SHOPIFY_TOKEN_REFRESH_SKEW seconds of expiry; REST/GraphQL helpers also refresh-and-retry once on a 401. Refresh is concurrency-safe (select_for_update), so parallel workers never double-spend the one-time-use refresh token.
  • Migration — a legacy non-expiring token is migrated to an expiring one on the next session-token exchange (lazy), 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.

With SHOPIFY_EXPIRING_TOKENS = False (default), behaviour is unchanged: non-expiring offline tokens, no refresh, no encryption key required.

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 receiver
  • shopify/gdpr-webhooks/customer-data-request — GDPR customer data request
  • shopify/gdpr-webhooks/customer-data-erasure — GDPR customer data erasure
  • shopify/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 header. 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']

@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)
python manage.py migrate_offline_tokens [--shop <domain>] [--dry-run]

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_shopify_app-2.4.0.tar.gz (27.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_shopify_app-2.4.0-py3-none-any.whl (29.3 kB view details)

Uploaded Python 3

File details

Details for the file django_shopify_app-2.4.0.tar.gz.

File metadata

  • Download URL: django_shopify_app-2.4.0.tar.gz
  • Upload date:
  • Size: 27.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for django_shopify_app-2.4.0.tar.gz
Algorithm Hash digest
SHA256 b2d3f1a4ccd07762b475a64b77c74ed95d81fd3145fc74dcb4d72488eb995940
MD5 28d7fdd568c71cb1e04f15ac42e066cd
BLAKE2b-256 de82757e8eebbadada069d9dbe034a496b3532a1c9ba829bdc511af5d453667c

See more details on using hashes here.

File details

Details for the file django_shopify_app-2.4.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_shopify_app-2.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 677d6bfad84b3e400902b2fa426374dc42657708da38a2daadaf0c11bcf305c3
MD5 942356e255453318dafc312870d356f4
BLAKE2b-256 1728545ce3c3d738f8a93cd2457a848ea8274b7ed83a130a5092ef17173aecb0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page