Skip to main content

django-shopify-toolkit

PyPI Version Python Versions Django Versions License: MIT

A reusable, installable Django app that gives any Django project Shopify Admin GraphQL integration out of the box — working database tables, an admin-registered ShopifyStore model with encrypted credentials, and a Python API (plus optional REST endpoints) for products, staged image uploads, product variants and options, orders, customers, webhooks, and bulk data exports.

The package is multi-tenant: one Django install can manage many Shopify stores, each with its own encrypted credentials. Every service function takes an explicit ShopifyStore — there is no global or default store.


Key Features

  • Modern GraphQL (2024-07 through 2026-07+): Full compatibility with Shopify's latest GraphQL APIs (productVariantsBulkCreate, stagedUploadsCreate, fulfillmentCreateV2).
  • Multi-Tenant & Encrypted: Stores encrypted access tokens and API secrets per tenant using django-encrypted-model-fields.
  • Fail-Safe Sanitization: Automatically handles currency symbols ($, €), comma decimals, non-numeric strings, and weight unit normalizations (kg, lbs, oz, grams).
  • Staged Image Uploads: Direct binary file uploads to Shopify CDN via Staged Uploads.
  • Webhook Receiver & Signal Dispatcher: Built-in HMAC SHA-256 verification and webhook_received Django signals.
  • Asynchronous Bulk Operations: Query hundreds of thousands of records via Shopify's JSONL bulk export pipeline.
  • Typed Exceptions: Catch clean typed exceptions (ShopifyGraphQLError, ShopifyUserError, ShopifyRateLimitError, ShopifyStoreNotConfigured) instead of raw HTTP errors.
  • Multi-Database: PostgreSQL/MySQL/SQLite by default (Postgres is what this package's own CI runs against); MongoDB and a dedicated PostgreSQL schema are both supported as opt-in extras — see "Database support" below.

Requirements

  • Python 3.10, 3.11, or 3.12
  • Django 4.2 or 5.x

Database support

This package works against a normal relational database out of the box — nothing below is required reading unless you need one of these specifically:

Backend / setup What it needs Status
PostgreSQL Nothing — the default. Fully tested; this package's own CI runs against it.
MySQL Nothing — the default. Expected to work (same relational path as Postgres); not independently verified in this package's own CI.
SQLite Nothing — the default. Works, but Client Credentials Grant token exchange loses its concurrency-safety guarantee (SELECT ... FOR UPDATE is a no-op) — fine for local dev, not recommended for production under real concurrent load.
MongoDB pip install django-shopify-toolkit[mongodb] + two settings Supported, verified end-to-end against a real MongoDB instance. Same Client Credentials Grant caveat as SQLite. See "Running on MongoDB" below.
Dedicated PostgreSQL schema for this package's own tables One setting, fresh installs only Supported, verified end-to-end against real Postgres. See "Using a dedicated PostgreSQL schema" below.
Schema-per-tenant (e.g. via django-tenants) Not this package's own feature This package's models are schema-agnostic by design and should work as a TENANT_APPS entry unmodified, but that specific path hasn't had its own end-to-end verification yet — see MULTI_DB_SUPPORT_PLAN.md in this repo.

Installation

pip install django-shopify-toolkit

# With optional Django REST Framework serializers and viewsets
pip install django-shopify-toolkit[drf]

# With test helpers for your own test suite
pip install django-shopify-toolkit[testing]

Quick Start

1. Environment Variables (.env)

Two secrets are process-wide and must never live in the database or in source control — keep them in your .env/secrets manager. Nothing else needs to come from env; per-store credentials (access token, client ID/secret, API version, scopes) live on the ShopifyStore model instead — see "Connecting a Shopify store" and "Multiple stores" below for why.

# Required: encrypts ShopifyStore's access_token/client_id/client_secret columns at rest.
# Keeping this OUTSIDE the database is the actual security boundary — a DB-only leak (backup,
# SQL injection) then only exposes ciphertext, not usable tokens.
FIELD_ENCRYPTION_KEY="your-32-byte-base64-encryption-key"

# Only required if you use the webhook receiver (nothing reads this otherwise, checked lazily
# on the first incoming webhook — not required just to install the package). Verifies the
# X-Shopify-Hmac-Sha256 header. One value for the whole app, not per-store — that's how Shopify
# webhook signing actually works for a single app's subscriptions.
SHOPIFY_TOOLKIT_API_SECRET="your_app_webhook_secret"

Tip: Generate a valid 32-byte key in 1 command:

python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"

Set FIELD_ENCRYPTION_KEY correctly before creating your first ShopifyStore row. Changing or losing it afterward — without rotating via the list form (see "Key rotation" below) — makes every already-encrypted value permanently undecryptable. There is no recovery path once that happens; this is inherent to how Fernet encryption works, not something this package can guard against after the fact.


2. Configure settings.py

Add shopify_toolkit to INSTALLED_APPS and wire up the env var(s) above. Every SHOPIFY_TOOLKIT_* setting is a flat top-level Django setting, not a nested dict — see the "Settings reference" section below for the complete list.

import os

INSTALLED_APPS = [
    ...,
    "shopify_toolkit",
]

# os.environ[...], not .get(..., "") — fail at startup with a clear error pointing at the
# missing var, rather than a less obvious error several layers down inside the encryption
# library (which does still fail on an empty key, just less legibly).
FIELD_ENCRYPTION_KEY = os.environ["FIELD_ENCRYPTION_KEY"]
SHOPIFY_TOOLKIT_API_SECRET = os.environ.get("SHOPIFY_TOOLKIT_API_SECRET", "")  # only if using webhooks

Key rotation: FIELD_ENCRYPTION_KEY also accepts a list — django-encrypted-model-fields wraps it in cryptography.fernet.MultiFernet, which tries every key when decrypting (so already-encrypted rows keep working) but always uses the first key when encrypting (so new writes move to the new key immediately):

FIELD_ENCRYPTION_KEY = [
    "new-key-goes-first",   # used for all new writes from now on
    "old-key-goes-second",  # kept only so existing rows still decrypt
]

Existing rows aren't automatically rewritten under the new key — that only happens the next time each row is saved. Set this key correctly before the first ShopifyStore is ever created: losing it, or replacing it outright instead of rotating, makes every row encrypted under the old key permanently undecryptable.


3. Run Migrations

python manage.py migrate

Settings Reference

All of these are optional except SHOPIFY_TOOLKIT_API_SECRET (only required if you use the webhook receiver). Every one is read in exactly one place (shopify_toolkit/conf.py) — nothing in this package reads django.conf.settings directly anywhere else.

Setting Default What it controls
SHOPIFY_TOOLKIT_API_SECRET (none — required for webhooks) HMAC secret used to verify incoming webhook signatures.
SHOPIFY_TOOLKIT_API_VERSION "2026-07" Default Shopify Admin API version for stores that don't set their own api_version. Shopify supports each version ~12 months — check this isn't stale.
SHOPIFY_TOOLKIT_MAX_RETRIES 3 Retry attempts for transient network errors in ShopifyGraphQLClient.
SHOPIFY_TOOLKIT_RATE_LIMIT_BUFFER 100 Pause outgoing requests once Shopify's cost-throttle bucket drops below this.
SHOPIFY_TOOLKIT_MAX_BACKOFF_SECONDS 10 Cap on any single blocking sleep (retry or rate-limit backoff), so a slow Shopify can't tie up a sync worker indefinitely.
SHOPIFY_TOOLKIT_REQUEST_TIMEOUT 30 Per-request timeout (seconds) for GraphQL calls and token exchanges.
SHOPIFY_TOOLKIT_MAX_POOLED_SESSIONS 500 Max distinct store domains kept in the HTTP session pool at once; least-recently-used sessions are evicted and closed beyond this.
SHOPIFY_TOOLKIT_DEFAULT_AUTO_FIELD "django.db.models.BigAutoField" Primary-key field class for ShopifyStore/WebhookEvent. Only override this on MongoDB — see "Running on MongoDB" below.

Usage Guide

1. Connecting a Shopify Store

Two ways to authenticate a store — set whichever applies to that store, not both:

Static token (pre-2026 custom apps — a token shown once in the Shopify admin, never expires):

from shopify_toolkit.models import ShopifyStore

store, created = ShopifyStore.objects.update_or_create(
    shop_domain="your-store.myshopify.com",
    defaults={
        "access_token": "shpat_xxxxxxxxxxxxxxxxxxxxx",
        "api_version": "2026-07",
        "is_active": True,
    }
)

Client ID/Secret (Dev Dashboard apps, 2026+ — no static token is shown at all; the package exchanges these for a real access token on first use and refreshes it automatically roughly every 24 hours, caching the result on this same row):

store, created = ShopifyStore.objects.update_or_create(
    shop_domain="your-store.myshopify.com",
    defaults={
        "client_id": "your_app_client_id",
        "client_secret": "your_app_client_secret",
        "api_version": "2026-07",
        "is_active": True,
    }
)

update_or_create, not get_or_create — deliberately. get_or_create only applies defaults when creating a new row; if shop_domain already exists it just returns that row untouched, silently ignoring any updated credentials you passed. That's the wrong behavior for onboarding code that might run more than once (a re-run install flow, a retried signup) with rotated or corrected credentials — update_or_create applies defaults on match too, so re-running this safely refreshes the row instead of either crashing (bare .create(), which would hit shop_domain's unique constraint) or silently doing nothing (get_or_create).

access_token, client_id, and client_secret are all encrypted at rest (see the FIELD_ENCRYPTION_KEY note above) — verified by reading the raw stored values directly; the ORM decrypts transparently, so nothing about reading store.access_token in your own code changes.


1a. Multiple stores

There's no per-store configuration in settings.py and no SHOPIFY_STORES list — multi-tenancy is just "more than one ShopifyStore row," since every function in this package takes an explicit store argument (there is no global/default store, see the intro above). Two examples already built into the package show the pattern — resolve which store a request is for, then pass that instance down:

  • The webhook receiver resolves the store from the X-Shopify-Shop-Domain header automatically (see "Webhooks" below) — nothing to do for this one.
  • The optional DRF layer resolves the store from the URL: every endpoint is nested under /stores/<store_pk>/... (see "Optional: DRF API layer" below).

For your own code (management commands, views, scheduled jobs), do the same — resolve the store from whatever your app already knows, then call any service function with it:

# e.g. a nightly sync across every active store
for store in ShopifyStore.objects.filter(is_active=True):
    for product in products.iter_all_products(store):
        ...

2. Products & Staged Image Uploads

from shopify_toolkit.products import services as products
from shopify_toolkit.images import services as images

# Create a Product
product = products.create_product(
    store,
    title="Nimbus Running Shoes",
    vendor="Apex Athletics",
    status="ACTIVE",
    description_html="<p>Lightweight high-performance running shoes.</p>"
)

# Upload a Local Image File directly to Shopify CDN
with open("shoe_photo.jpg", "rb") as file_obj:
    media = images.upload_product_image_file(
        store,
        product_id=product.id,
        file_source=file_obj,
        filename="shoe_photo.jpg",
        alt_text="Nimbus Running Shoes - Side View"
    )

# Or attach an image from a public URL
image = images.add_product_image(
    store,
    product_id=product.id,
    src="https://images.unsplash.com/photo-1542291026-7eec264c27ff",
    alt_text="Nimbus Red"
)

# List Products (paginated)
page = products.list_products(store, first=20)
for prod in page.products:
    print(prod.title, len(prod.variants), len(prod.images))

3. Product Options & Variants

# 1. Create Options (e.g. Size, Color)
option = products.create_product_option(
    store,
    product_id=product.id,
    name="Size",
    values=["US 9", "US 10", "US 11"]
)

# 2. Create Variant with Price, SKU, Weight, and Image attachment
variant = products.create_product_variant(
    store,
    product_id=product.id,
    price="139.99",
    compare_at_price="179.99",
    sku="NIMBUS-RED-10",
    weight=0.75,
    weight_unit="lbs",
    media_id=media.id,
    option_values=[{"optionName": "Size", "name": "US 10"}]
)

# 3. Update Variant
products.update_product_variant(
    store,
    product_id=product.id,
    variant_id=variant.id,
    price="129.99",
    sku="NIMBUS-RED-10-SALE"
)

# 4. List Variants
variants = products.list_product_variants(store, product.id)

4. Orders & Fulfillments

from shopify_toolkit.orders import services as orders

# List Orders
orders_page = orders.list_orders(store, first=25)

# Get Single Order
order = orders.get_order(store, "gid://shopify/Order/123456789")

# Fulfill an Order via FulfillmentOrder
orders.update_fulfillment_status(
    store,
    fulfillment_order_id="gid://shopify/FulfillmentOrder/987654321",
    tracking_number="1Z9999999999999999",
    tracking_company="UPS",
    notify_customer=True
)

# Iterate over all orders across pages
for order in orders.iter_all_orders(store):
    print(order.name, order.total_price, order.fulfillment_status)

5. Customers

from shopify_toolkit.customers import services as customers

# Create Customer
customer = customers.create_customer(
    store,
    first_name="Ada",
    last_name="Lovelace",
    email="ada@example.com",
    phone="+1234567890"
)

# List Customers
cust_page = customers.list_customers(store, first=30)

6. Webhooks & Signal Handling

# urls.py - exposes POST /shopify/webhooks/receive/
from django.urls import include, path

urlpatterns = [
    ...,
    path("shopify/webhooks/", include("shopify_toolkit.webhooks.urls")),
]

Incoming webhooks are automatically verified using HMAC SHA-256 and saved as WebhookEvent models. You can also react to webhooks anywhere in your project using Django signals:

from django.dispatch import receiver
from shopify_toolkit.signals import webhook_received

@receiver(webhook_received)
def on_order_created(sender, store, topic, payload, event, **kwargs):
    if topic == "orders/create":
        order_id = payload.get("id")
        print(f"New order received for {store.shop_domain}: {order_id}")

7. Asynchronous Bulk Operations

For datasets with tens of thousands of records, run Shopify Bulk Operations:

from shopify_toolkit.bulk_operations import services as bulk

# Run asynchronous bulk query
operation = bulk.run_bulk_query(
    store,
    query="{ products { edges { node { id title variants { edges { node { id sku price } } } } } } }"
)

# Wait for Shopify to complete the job and download JSONL results
completed_op = bulk.wait_for_bulk_operation(store, poll_interval=2.0, timeout=300.0)
records = bulk.download_bulk_operation_result(completed_op)

8. Testing Your Code

from shopify_toolkit.testing import mock_shopify_api, mock_shopify_error

def test_product_query(shopify_store):
    with mock_shopify_api(shopify_store, {"data": {"products": {"edges": []}}}):
        # Code under test
        ...

def test_api_error_handling(shopify_store):
    with mock_shopify_error(shopify_store, "Shopify API Throttled"):
        # Code under test raises ShopifyGraphQLError
        ...

9. Running on MongoDB (optional)

By default this package targets a relational database (Postgres/MySQL/SQLite) with an integer BigAutoField primary key on ShopifyStore/WebhookEvent. MongoDB doesn't support AutoField/BigAutoField as a primary key at all, so running this app on Mongo needs two extra steps beyond a normal install — see MULTI_DB_SUPPORT_PLAN.md in this repo for the full research and rationale behind this.

pip install django-shopify-toolkit[mongodb]
# settings.py

# 1. Point this app's primary key at MongoDB's ObjectId type instead of the relational default.
#    This only affects shopify_toolkit's own models — it does not touch Django's project-wide
#    DEFAULT_AUTO_FIELD, so the rest of your project can stay on a different backend/PK type.
SHOPIFY_TOOLKIT_DEFAULT_AUTO_FIELD = "django_mongodb_backend.fields.ObjectIdAutoField"

# 2. Point this app at a separate migration history built for that PK type — the normal
#    migrations/ history is frozen with BigAutoField and can't be replayed against Mongo.
MIGRATION_MODULES = {
    "shopify_toolkit": "shopify_toolkit.migrations_mongodb",
}

Known limitation: the row-level locking that protects Client Credentials Grant token exchange from concurrent duplicate requests (SELECT ... FOR UPDATE) only runs on backends that support it — currently Postgres and MySQL. On MongoDB (same as SQLite) it's skipped entirely; the code still re-checks for an already-refreshed token before exchanging, so this doesn't cause incorrect behavior, just a small chance of two near-simultaneous requests each independently hitting Shopify's OAuth endpoint under heavy concurrent load. If you're running Client Credentials Grant stores on MongoDB under real concurrent traffic, be aware of this — it is not the same guarantee Postgres/MySQL get.

This path is less exercised than the Postgres path this package's own CI runs against — verify it against a real MongoDB instance before relying on it in production.


10. Using a dedicated PostgreSQL schema (optional)

By default ShopifyStore/WebhookEvent land in whatever schema your database connection resolves unqualified table names to (public, almost always). If you'd rather keep this package's tables in their own schema — separate from the rest of your project's tables, for cleaner management — set SHOPIFY_TOOLKIT_DB_SCHEMA:

# settings.py
SHOPIFY_TOOLKIT_DB_SCHEMA = "shopify_data"

The schema itself is created automatically (CREATE SCHEMA IF NOT EXISTS) as part of running migrate — nothing to do there.

Fresh installs only — set this before the very first migrate on a new project. This isn't just an unverified caveat: tested directly, including trying to make an upgrade path work, and it genuinely doesn't. Setting SHOPIFY_TOOLKIT_DB_SCHEMA on a project that has already run migrate and then running migrate again does nothing — Django only knows "this migration is already applied," not what it would do differently now, so it's skipped. Forcing it via manage.py migrate shopify_toolkit 0001 (unapply) and then migrate again to reapply doesn't work either — the reverse operation also reads the migration file's current (now schema-set) content and errors trying to move tables out of a schema they were never actually in. If you have an existing install and want to move into a dedicated schema, do it directly and manually — a one-time CREATE SCHEMA IF NOT EXISTS shopify_data; followed by ALTER TABLE shopify_toolkit_shopifystore SET SCHEMA shopify_data; (and the same for shopify_toolkit_webhookevent) — not by flipping this setting.

PostgreSQL only — MySQL has no equivalent namespace-within-a-database concept, and SQLite has nothing like it either; don't set this on those backends.

This is different from schema-per-tenant multi-tenancy (one schema per client, e.g. via django-tenants) — this setting gives the whole package one dedicated schema, host-wide, not one schema per ShopifyStore. If you need true per-tenant schema isolation, django-tenants is the right tool for that — this package's models are schema-agnostic (no raw SQL, no hardcoded table/schema names) and were designed to work as a TENANT_APPS entry unmodified, though that specific path is still pending its own end-to-end verification — see MULTI_DB_SUPPORT_PLAN.md.


11. Optional: DRF API layer

pip install django-shopify-toolkit[drf] gets you REST endpoints wrapping the products/orders/ customers services above, active only if djangorestframework is installed. Every endpoint is nested under a store, matching the multi-store pattern above:

# urls.py
from django.urls import include, path

urlpatterns = [
    ...,
    path("api/", include("shopify_toolkit.api.urls")),
]
GET/POST   /api/stores/<store_pk>/products/
GET/PUT/PATCH/DELETE  /api/stores/<store_pk>/products/<pk>/
GET        /api/stores/<store_pk>/orders/
GET        /api/stores/<store_pk>/orders/<pk>/
POST       /api/stores/<store_pk>/orders/<pk>/fulfill/
GET/POST   /api/stores/<store_pk>/customers/
GET/PUT/PATCH  /api/stores/<store_pk>/customers/<pk>/

These viewsets ship with no authentication_classes/permission_classes set — they inherit whatever DEFAULT_AUTHENTICATION_CLASSES/DEFAULT_PERMISSION_CLASSES your project's own REST_FRAMEWORK setting defines, or DRF's own defaults (AllowAny) if you haven't set any. That means these endpoints are open by default — set your project's REST_FRAMEWORK permission classes (or add authentication/permission classes to a subclass of these viewsets) before exposing them publicly.


License

MIT License.

Metadata

Release files for django-shopify-toolkit 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-shopify-toolkit 1.2.0
File Size Uploaded
django_shopify_toolkit-1.2.0.tar.gz 61.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-shopify-toolkit 1.2.0
File Interpreter ABI Platform
django_shopify_toolkit-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 118.5 kB

Release files / django_shopify_toolkit-1.2.0.tar.gz

Download URL django_shopify_toolkit-1.2.0.tar.gz
Size 61.9 kB
Tags Source
SHA-256 checksum
How to use checksums
1a6af2edd5597e0f0ca96cd2a5c14d1c8df993ded65faa42751d9c95f581aa7b
BLAKE2b-256 checksum
How to use checksums
cb0f86fbb560692350107bd6b3f785fbe434f4f21155e2aae1094c77c1b444d1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release files / django_shopify_toolkit-1.2.0-py3-none-any.whl

Download URL django_shopify_toolkit-1.2.0-py3-none-any.whl
Size 56.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
124d21719fac5b0217f0b447f1dac38a91323e3396c2f3cdceee6bbd20d3f723
BLAKE2b-256 checksum
How to use checksums
9b414342eb64f449346301979c7b72af1e7b9ece45450bd53f2635c4d7a9ab3f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page