ERPNext Africa · integration playbook

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
Target architecture
  • Live order push — Shopify webhooks → a direct, DNS-only (grey-cloud) path → Caddy (auto-TLS) → the ERPNext ecommerce_integrations connector. 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.
Every step below exists because something broke without it — treat the checklists as non-negotiable.

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 (add write_* 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.
Why "same app" mattersShopify signs each webhook with the API secret of the app that owns the webhook subscription (the app whose token created it). If the token and secret come from different apps/versions, HMAC fails forever and looks like a code bug. Verify the token's app with GraphQL { 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 .
Never tag your image 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/.envcompose.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:

PatchWhereChange
Fetch all ordersshopify/order.py · _fetch_old_ordersAdd status="any" to Order.find(...). The default status=open returns only unarchived orders — you silently miss most history.
Long item namesshopify/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 toleranceshopify/order.py · sync_old_ordersWrap 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.com
  • password = the Admin API access token (shpat_…). The token goes in the field literally named password — a field called access_token is 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_invoice on 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.

Verify a real delivery — don't guessTake a failed order's stored raw body + Shopify's 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

  1. Catalog first — run import_all_products; confirm products + variants → Items + Ecommerce Item maps.
  2. Then history — with read_all_orders + the status="any" patch, set sync_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.
Don't import and order-sync at the same timeThey deadlock creating the shared 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:

ScenarioExpected
Catalog pullProducts + variants → Items
orders/create webhookSales Order created, HMAC validates
orders/paidSales Invoice + Payment Entry (needs leaf cash account)
orders/fulfilledDelivery Note (needs valuation / stock)
orders/cancelledSales Order cancelled
Historical pullAll-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 password field, not access_token.
  • Image uniquely tagged + PULL_POLICY=never + COMPOSE_FILE set — 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_account is a leaf, not a Group.
  • Import catalog and orders sequentially, not concurrently.
  • Healthy RQ workers; if a migrate_from_old_connector job loops and jams the queue, set is_old_data_migrated=1 and flush the redis-queue.
Known ceiling — what won't reach 100%Historical orders that reference products deleted from Shopify (the line item has no 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