Shopify → ERPNext Integration Playbook
Standing up an ERPNext instance that syncs from a customer's Shopify store. Default model: Shopify is the source of truth, one-direction (read-only) sync. Unlike the ERP-migration playbooks, this is a live integration — a connector, webhooks, and a backfill — not a one-time data move.
Reusable venture IP · replace every<placeholder> per customer
- Live order push — Shopify webhooks → a direct, DNS-only (grey-cloud) path → Caddy (auto-TLS) → the ERPNext
ecommerce_integrationsconnector. The CDN edge never touches the signed request body. - Pull — scheduled catalog + historical-order sync for everything webhooks don't cover.
- UI — reached via a Cloudflare tunnel; webhooks via the separate direct path.
01 · Intake — collect before you start
Most failures trace back to a wrong or mismatched credential. Get these exactly:
- Shopify store domain:
<store>.myshopify.com - ONE Shopify app (custom or Dev-Dashboard). From that same app: the Admin API access token (
shpat_…) and the API secret key / Client secret (shpss_…). - Scopes:
read_products, read_orders, read_all_orders, read_customers, read_inventory(addwrite_*only to push back — default is read-only). - Company legal name, base currency, Chart-of-Accounts basics, default warehouse, tax account(s), shipping/freight account, and a leaf cash/bank account.
- Sync scope — which objects, and whether Sales Invoices / Delivery Notes should be created.
{ currentAppInstallation { app { id title handle } } }.02 · ERPNext build — a pull-proof image
Build a custom image (apps baked in, uniquely tagged)
Use frappe_docker and build via apps.json so every container shares one image with the apps baked in — never install apps at runtime.
// apps.json (frappe is implicit)
[
{ "url": "https://github.com/frappe/erpnext", "branch": "version-15" },
{ "url": "https://github.com/frappe/ecommerce_integrations", "branch": "main" },
{ "url": "<localization-app-repo>", "branch": "<branch>" }
]
export APPS_JSON_BASE64=$(base64 -w0 apps.json)
docker build -t <customer>/erpnext:1.0 \
--build-arg=FRAPPE_BRANCH=version-15 \
--build-arg=APPS_JSON_BASE64=$APPS_JSON_BASE64 \
--file=images/layered/Containerfile .
frappe/erpnext:v15It collides with the public Docker Hub image; a docker compose up with the default PULL_POLICY=always will silently pull the app-less Hub image over yours and break every worker. Always use a customer-namespaced tag.Pin the deployment (the recurrence-proof part)
In ~/frappe_docker/.env — compose.yaml already references these on every app service:
CUSTOM_IMAGE=<customer>/erpnext
CUSTOM_TAG=1.0
PULL_POLICY=never
COMPOSE_FILE=compose.yaml:overrides/compose.mariadb.yaml:overrides/compose.redis.yaml
Setting COMPOSE_FILE means a plain docker compose up -d always loads db + redis (no partial-up that splits the network). Push the image to a registry (e.g. Artifact Registry) for off-box durability. Ingress: UI via a Cloudflare tunnel; webhooks via a separate DNS-only record → Caddy → frontend:8080 with a Host rewrite.
03 · Connector & configuration
Patch the connector (bake into the image)
Stock ecommerce_integrations has three gaps that block a clean go-live:
| Patch | Where | Change |
|---|---|---|
| Fetch all orders | shopify/order.py · _fetch_old_orders | Add status="any" to Order.find(...). The default status=open returns only unarchived orders — you silently miss most history. |
| Long item names | shopify/order.py (SO line) + shopify/product.py (_create_item) | Truncate item_name to 140. ERPNext caps it at 140; long Shopify titles throw CharacterLengthExceededError and abort the run. |
| Fault tolerance | shopify/order.py · sync_old_orders | Wrap the per-order sync_sales_order(...) in try/except + rollback() so one bad record can't abort the whole backfill. |
Configure the Shopify Setting
shopify_url=<store>.myshopify.compassword= the Admin API access token (shpat_…). The token goes in the field literally namedpassword— a field calledaccess_tokenis ignored and API calls 401.shared_secret= the API secret of the same app (shpss_…).company,warehouse,price_list,default_customer,customer_group.cash_bank_account= a leaf account (NOT a Group) — else paid→Payment Entry fails.- Tax + shipping/freight accounts mapped; fixtures present (Address Template, UOMs e.g.
Kg). sync_delivery_note/sync_sales_invoiceon only if wanted; Delivery Notes need a valuation rate (or Allow Zero Valuation Rate) + stock.
04 · Webhooks & backfill
Webhooks
Register on the store, pointing at the direct endpoint:
https://<hook-host>/api/method/ecommerce_integrations.shopify.connection.store_request_data
Topics: orders/create, orders/paid, orders/fulfilled, orders/cancelled, orders/partially_fulfilled. HMAC = base64(hmac_sha256(shared_secret, raw_body)) vs the X-Shopify-Hmac-Sha256 header — passes only if shared_secret is the signing app's secret.
X-Shopify-Hmac-Sha256 and check hmac_sha256(secret, body) matches. Matches at the edge but the connector rejects it → body-mutation problem. Doesn't match → wrong secret (usually a different-app secret).Catalog + historical backfill
- Catalog first — run
import_all_products; confirm products + variants → Items + Ecommerce Item maps. - Then history — with
read_all_orders+ thestatus="any"patch, setsync_old_orders=1+ the date window and run the pull detached (docker exec -d … bench … execute ecommerce_integrations.shopify.order.sync_old_orders) so an SSH drop can't kill a long inline job.
Item Attribute ("Variant"). Import the catalog first, then run the order backfill.05 · Validate & monitor
Validate (the golden matrix)
Prove each link on a controlled test store before trusting live data:
| Scenario | Expected |
|---|---|
| Catalog pull | Products + variants → Items |
orders/create webhook | Sales Order created, HMAC validates |
orders/paid | Sales Invoice + Payment Entry (needs leaf cash account) |
orders/fulfilled | Delivery Note (needs valuation / stock) |
orders/cancelled | Sales Order cancelled |
| Historical pull | All-time orders via status=any |
Monitor
Stand up a small read-only Sync Console — a Cloudflare Worker proxying ERPNext + Shopify server-side (tokens never reach the browser): live coverage (Shopify vs ERPNext counts), order-sync health, a live feed, and a "Not synced — orders & why" panel that diffs the two and categorizes each gap (deleted product / cancelled / recent-pending-webhook / other).
Risks & gotchas — the expensive lessons
- Token + secret from the same Shopify app (HMAC depends on it).
- Admin token goes in the
passwordfield, notaccess_token. - Image uniquely tagged +
PULL_POLICY=never+COMPOSE_FILEset — never collide with Docker Hub. - Apps baked into the image, never installed at runtime.
- Connector patches applied:
status="any", item_name 140-truncate, per-order try/except. cash_bank_accountis a leaf, not a Group.- Import catalog and orders sequentially, not concurrently.
- Healthy RQ workers; if a
migrate_from_old_connectorjob loops and jams the queue, setis_old_data_migrated=1and flush the redis-queue.
product_id) can't auto-sync — there is no source product to map to an ERPNext Item. Cancelled-only and deleted-variant orders are similar. Expect ~98% historical coverage; the rest is structural, not a bug — the "Not synced" panel quantifies it per customer.Sources
Connector: frappe/ecommerce_integrations · shopify/order.py
Shopify: Verify webhook HMAC · Admin API — Order · Access scopes (read_all_orders) · Client secrets & rotation
ERPNext / deploy: frappe_docker — custom apps image · ERPNext Data Import
Shared method & programme: ERPNext Africa playbooks · erp.2nth.ai