BaseUp Labs · Odoo 20 Module

Shopify Connector
Feature Handbook

Everything the baseup_shopify module does — a bidirectional Shopify integration for Odoo 20 built on the GraphQL Admin API, with real-time webhooks, multi-warehouse inventory mapping, and direction-aware sync controls. Everything described here ships in the single module; there is nothing further to buy.

Module
baseup_shopify
Version
20.0.1.0.0
Odoo
20.0
Shopify API
2026-04
License
OPL-1
Author
BaseUp Labs
Download PDF Day-to-day tasks are in the Operator Guide; installing it is the Developer Setup Guide.

Section IWhat you have bought

The Shopify Connector links one or more Shopify stores to Odoo 20 and keeps products, inventory, orders, fulfillments and refunds aligned in both directions. It talks to Shopify's GraphQL Admin API (version 2026-04) for all business operations, falling back to REST only for the two endpoints that have no GraphQL equivalent: the connection test (/shop.json) and webhook registration (/webhooks.json).

Capability summary

AreaShopify → OdooOdoo → Shopify
ProductsFull catalogue import, incremental and full resyncCreate & update, optional auto-push on save
VariantsAttributes, options, prices, images, SKUsCreate missing, delete removed, price & image push
InventoryPull on-hand levels (Shopify wins)Push on-hand levels (Odoo wins), per-warehouse
OrdersImport with auto-confirm on paymentQuotations exported as draft orders
Draft ordersImport as Odoo quotations, promoted on completionPush confirmed quotations
FulfillmentTracking numbers written to Odoo pickingsFulfillment created on delivery validation
RefundsDraft credit notes from refund webhooks—
CancellationsCancelled & voided orders cancel the Odoo SO—
CustomersPartners & child addresses created on importCustomer payload on exported draft orders
LocationsShopify locations become Odoo warehousesOdoo warehouses become Shopify locations

Odoo dependencies

The module installs on top of these standard Odoo apps, all of which must be available:

DependencyWhy it is needed
sale_managementSale orders and quotations — the target of order import
website_saleeCommerce product fields used by the catalogue mapping
stockWarehouses, quants and pickings for inventory and fulfillment
productProduct templates, variants and attribute values
accountInvoices and credit notes for the refund flow
deliveryDelivery carriers, matched by name for tracking sync
stock_deliverycarrier_tracking_ref on deliveries for tracking sync

Where to find it in Odoo

Installation adds a top-level Shopify app (visible to Sales Managers) with the following menu structure:

Section IIHow the connector is built

The module follows a strict separation between transport, business services and mapping records. No Odoo core addon is modified; all ERP behaviour is added through _inherit extensions inside this module.

ODOO UI & CORE EXTENSIONS product.template · product.product · sale.order · sale.order.line · stock.picking · stock.quant MAPPING RECORDS shopify.account · shopify.instance shopify.product · .variant · .order · .log SYNC SERVICES shopify.product.sync shopify.order.sync TRANSPORT — shopify.api GraphQL POST · cursor pagination · staged uploads · error mapping CONTROLLER /shopify/webhook/… · /shopify/oauth/callback SHOPIFY Admin API 2026-04 requests webhooks
Requests flow down through one transport model; Shopify pushes changes back into the webhook controller.

Model responsibilities

ModelKindResponsibility
shopify.accountStoredStore credentials, configuration, per-store action buttons, cron entry points
shopify.instanceStoredRuntime handle (_inherits on the account) passed into every sync call; HMAC verification; location resolution
shopify.apiAbstractGraphQL transport, cursor pagination, staged file uploads
shopify.product.syncAbstractProduct import/export, variant mapping, images, inventory push & pull
shopify.order.syncAbstractOrder and draft-order import, fulfillment push, refund import
shopify.productStoredShopify product GID ↔ product.template mapping, sync state
shopify.product.variantStoredShopify variant & inventory-item GID ↔ product.product mapping
shopify.orderStoredShopify order GID ↔ sale.order mapping, financial & fulfillment status
shopify.location.mappingStoredShopify location GID ↔ stock.warehouse pairs
shopify.logStoredOperation audit trail
shopify.sync.wizardTransientDirection-aware manual sync
shopify.push.wizardTransientProduct push with explicit store selection

Design rules the module follows

  1. Odoo core addons (sale, stock, product) are never modified directly.
  2. ERP behaviour is added through _inherit in this module only.
  3. Mapping records stay separate from the sync service implementations.
  4. Transport concerns live in shopify.api, never in a business service.

Section IIIConnecting a store

Each Shopify store is one shopify.account record under Shopify → Configuration → Shopify Accounts. Multiple stores can be connected simultaneously; every mapping record is scoped to its instance.

OAuth connection (recommended)

The connector implements Shopify's Authorization Code Grant flow for apps created in the Shopify Dev Dashboard. You supply the Client ID and Client Secret; the access token and webhook secret are filled in automatically.

  1. Step 1Enter Shop URL, Client ID and Client Secret, then save.
  2. Step 2Click Connect to Shopify — a random state token is generated and you are redirected to Shopify.
  3. Step 3Approve the requested scopes in Shopify.
  4. Step 4Shopify returns to /shopify/oauth/callback, which exchanges the code for an access token.
  5. Step 5The token and webhook secret are stored, status becomes Connected, and a shopify.instance is created.

The callback verifies Shopify's hmac signature on the redirect, refuses a callback whose shop differs from the account's own store, and matches the returned state against the stored oauth_state, clearing it on completion. The client secret is only ever sent to the account's own store.

Public URL required

The OAuth redirect and webhook callbacks must reach your Odoo instance over public HTTPS. Set baseup_shopify.webhook_base_url in Settings → Technical → System Parameters to your public domain. It takes precedence over web.base.url, so a development server can keep web.base.url pointed at a LAN address.

Requested OAuth scopes

The connector requests exactly the scopes its features need:

Scope pairUsed for
read_products, write_productsCatalogue import and export, variants, images
read_orders, write_ordersOrder import, cancellation detection
read_inventory, write_inventoryInventory pull and push
read_fulfillments, write_fulfillmentsFulfillment creation and tracking updates
read_draft_orders, write_draft_ordersDraft order import and quotation export
read_locationsLocation discovery for warehouse mapping
read_merchant_managed_fulfillment_orders, write_merchant_managed_fulfillment_ordersReading and fulfilling the order's fulfillment orders

Account fields

FieldPurpose
Shop NameDisplay name; overwritten with the real shop name on a successful connection test
Shop URLmystore.myshopify.com — the https:// prefix is added automatically
Client IDOAuth client ID from the Shopify Dev Dashboard app
Client SecretOAuth client secret; also copied into the webhook secret on connect
Access TokenFilled by OAuth; can be entered manually for legacy custom apps
Webhook SecretUsed to verify the HMAC signature on every inbound webhook
CompanyOdoo company that owns the imported records
WarehouseDefault warehouse for imported orders and the legacy single-location fallback
Pricelist, Sales TeamApplied to imported sale orders
Sync Products / Orders / InventoryPer-store master switches; defaults come from the global settings
StatusDisconnected Connected Error — set by connect, test and failure paths

Credential fields (api_key, api_secret, webhook_secret, client_id, oauth_state) are restricted to the base.group_system group, so ordinary Sales Managers can operate the connector without seeing the secrets.

Test Connection

Test Connection calls the REST /shop.json endpoint — the one check with no GraphQL equivalent. On success the account is marked Connected, the shop name is refreshed and a shopify.instance is created if one does not exist. On failure the account is marked Error, which stays saved so the red ribbon shows, and the underlying message appears in a red notification.

Section IVWebhooks & real-time updates

Webhooks are what make the integration feel live: an order placed in Shopify appears in Odoo within seconds rather than waiting for the next cron tick.

Registration

Click Register Webhooks on a connected account. The connector deletes every existing webhook on the store first, then registers the full topic list against your current base URL. This guarantees a clean slate and removes stale callbacks left by earlier instances.

Registration refuses to run unless the effective base URL is public HTTPS — http://, localhost and loopback addresses are rejected with an explanatory error rather than silently registering unreachable callbacks.

Registered topics

TopicEffect in Odoo
orders/createImport the order, auto-confirming it when already paid
orders/updatedRefresh statuses, tracking, and cancel the SO when appropriate
orders/paidConfirm the Odoo sale order
orders/cancelledCancel the linked Odoo sale order
orders/fulfilledRecord fulfillment status and tracking
orders/partially_fulfilledSame handler as fulfilled
refunds/createCreate a draft credit note
products/createIgnored unless the product is already mapped (prevents push echoes)
products/updateRe-fetch the product via GraphQL and update Odoo
products/deleteHandle removal of the Shopify product
inventory_levels/updateApply the new Shopify stock level in Odoo
draft_orders/createCreate a draft Odoo quotation
draft_orders/updateUpdate the draft quotation

Endpoints

RouteNotes
/shopify/webhook/account/<account_id>/<topic>Current endpoint — keyed on the account, so it survives instance recreation
/shopify/webhook/<instance_id>/<topic>Legacy endpoint, retained for backward compatibility
/shopify/oauth/callbackOAuth code exchange (authenticated user route)

Topic slugs in the URL use underscores. The controller maps them back to real Shopify topics by trying each underscore boundary, so multi-word left-hand topics such as inventory_levels_update → inventory_levels/update resolve correctly.

Signature verification

Every request must carry a valid X-Shopify-Hmac-Sha256 header. The connector computes an HMAC-SHA256 of the raw request body using the stored webhook secret and compares it with hmac.compare_digest. Requests with a missing secret, a bad signature or a malformed body are rejected with 401 or 400 and never reach a handler.

Echo suppression

When Odoo pushes a product to Shopify, Shopify immediately fires products/update back. Left unchecked this causes write contention and pointless re-imports. The connector suppresses these echoes:

Concurrency handling

Webhook processing runs under PostgreSQL REPEATABLE READ, where a concurrent write causes a serialization failure. Each webhook is attempted up to four times with exponential backoff (0.25s, 0.5s, 1s, capped at 2s). Because a savepoint rollback keeps the stale snapshot, the connector performs a full transaction rollback between attempts so each retry gets a fresh snapshot. Non-serialization errors are logged to shopify.log immediately.

The endpoint returns 200 OK even after a handled failure, so Shopify does not enter a retry storm; the failure is recorded in the Odoo log for you to act on.

Section VLocations & warehouses

Shopify tracks stock per location; Odoo tracks it per warehouse. The shopify.location.mapping table pairs them, which is what makes true multi-warehouse inventory sync possible.

Sync Locations Shopify → Odoo

For each active Shopify location, the button:

Push Warehouses to Shopify Odoo → Shopify

Creates Shopify locations for Odoo warehouses that have no mapping yet, using the warehouse partner's address. Already-mapped warehouses are left alone. Use it after Sync Locations when Odoo has warehouses that Shopify does not know about.

Mapping constraints

Two unique constraints, enforced by the database, keep the pairing unambiguous:

Legacy single-location fallback

When no mapping rows exist, the connector falls back to the account's Warehouse field paired with a cached shopify_location_gid. If no GID is cached it queries Shopify's active locations and stores the first one; if the store has no active location at all it creates one from the warehouse address. Configuring explicit mappings is strongly preferred — the fallback exists only for single-warehouse upgrades from earlier versions.

Section VIProducts: Shopify → Odoo

Two import modes

Product import runs in one of two modes, chosen automatically per account.

ModeWhen it runsHow it walks the catalogue
Incremental Normal day-to-day operation GraphQL filter updated_at:>=…, starting a configurable lookback window before the last successful sync so nothing falls through the boundary
Full resync First-ever sync, or after Full Product Resync ID-cursor walk: products are processed in numeric-ID order and the last processed ID is persisted, so the run resumes exactly where it stopped

Why an ID cursor

A full catalogue import can exceed the Odoo cron time limit. Rather than restarting from scratch each tick, the account stores full_resync_cursor:

Each cron tick advances the cursor. When the walk completes, the cursor is cleared and last_product_sync_date is set, returning the account to incremental mode. Full Product Resync therefore returns immediately and queues the work for the next auto-sync cycle instead of blocking the UI.

What an import creates

For each Shopify product node the connector:

  1. finds the existing shopify.product mapping by GID, or the Odoo product referenced by the odoo-product-tmpl-… tag Shopify carries from an earlier push;
  2. creates or updates the product.template;
  3. builds the full variant structure — attributes, values and variants — to match Shopify's options;
  4. maps every variant into shopify.product.variant, storing variant and inventory-item GIDs;
  5. pulls product and variant images unless images are skipped;
  6. optionally pulls inventory levels for the mapped variants.

Performance controls

ControlEffect
Sync batch sizeRecords processed before a flush; also the batch ceiling in batch mode
Skip product images in bulk syncOmits image download during bulk runs — by far the most expensive part of a large import
Bulk context flagsBulk runs set shopify_skip_auto_push, shopify_bulk_product_sync and shopify_skip_inventory_pull so an import cannot trigger an outbound push back to Shopify

Error recovery

A product that fails mid-import is flagged state = 'error' on its mapping rather than aborting the run; a full resync moves past it. Two mechanisms recover them:

MechanismBehaviour
Retry Errored Products (hourly cron) Re-attempts every errored mapping in a fresh transaction. Most failures are serialization conflicts, which a new snapshot resolves.
Clean Up Errored Products (button) Checks each errored mapping against Shopify. Where the Shopify product no longer exists, the Odoo product is archived and the orphaned mapping deleted. Mappings whose Shopify product still exists are left for the retry cron. The notification reports exactly how many were checked, archived, deleted and left pending.

Section VIIProducts: Odoo → Shopify

The export sequence

Pushing one product.template runs a fixed five-step sequence, guarded by a per-product advisory lock so two workers cannot export the same product concurrently:

  1. 1 · ShellproductCreate or productUpdate
  2. 2 · PricesproductVariantsBulkUpdate
  3. 3 · StockinventorySetQuantities
  4. 4 · MappingStore variant GIDs in Odoo
  5. 5 · MediaReplace the product image

Field mapping

Shopify fieldOdoo source
titlename
descriptionHtmldescription_sale
vendorCompany name
productTypeProduct category name
tagsOn creation only: odoo-product-tmpl-<id>, used to re-identify the product on later imports

Self-healing on a missing product

If a mapped Shopify product has been deleted, productUpdate fails. The connector recognises that specific error, drops the stale mapping and recreates the product instead of leaving the mapping permanently broken.

Automatic pushes

Two settings control whether saving in Odoo reaches Shopify:

SettingTrigger
Auto-push newly created products Exports every newly created product template. Default off.
Auto-update products on save Re-exports an already-linked product when one of these fields changes: name, description_sale, categ_id, image_1920, active, default_code, barcode, list_price. Default off.
Multi-store guard

Auto-push of new products is deliberately skipped when more than one store is connected — silently pushing a new product to every store is never the right default. Use Push to Shopify on the product to choose the target store.

Push to Shopify wizard

Available on the product form and as a list action. It requires an explicit Shopify Store selection (pre-filled when exactly one store is connected) and reports how many products pushed and how many Shopify rejected. Each product is pushed inside its own savepoint, so one failure cannot roll back the rest.

Attribute changes trigger a re-export

Editing an attribute value's name, price_extra, image_1920 or sequence re-exports every affected product template, so variant names, prices and images stay aligned. Adding, changing or removing a template attribute line does the same. Both paths respect the Auto-update products on save setting.

Deleting a product

Deleting an Odoo product template archives its Shopify counterpart (status: ARCHIVED) rather than deleting it — order history in Shopify stays intact. The archive attempt is best-effort: a Shopify failure is logged but does not block the Odoo deletion.

Two-way product sync from the list

Sync Shopify Products on the product list pushes each selected mapped product and then pulls it back, per connected store. Unmapped products are counted as skipped, and the notification breaks down pushed, pulled, skipped and failed counts.

Section VIIIVariants, options & images

How a Shopify variant is matched to an Odoo variant

Correct variant matching is the hardest part of any Shopify integration. The connector normalises both sides to a comparable key: option names and values are lowercased, sorted alphabetically and joined — for example color=blue|size=xl. The single-variant pseudo-option Title / Default Title that Shopify adds to products with no real options is filtered out, so such products normalise to an empty key.

Resolution is attempted in this order:

  1. stored shopify.product.variant mapping by variant GID;
  2. parent product mapping, then option-key match within that template;
  3. on-demand product sync to build the missing variant structure, then retry the match once;
  4. inventory-item GID mapping;
  5. SKU match on default_code;
  6. product title match, creating a consumable template as a last resort.

Self-healing stale mappings

When a stored mapping resolves to a variant whose option key does not match the Shopify line's selectedOptions, the connector treats the mapping as stale. It re-matches within the parent template, corrects the mapping in place and logs a warning naming both the old and new variant. This repairs the wrong-variant mappings that positional fallback can produce when SKUs are absent.

Successful fallback resolutions are written back as mappings, so the expensive chain runs once and later imports hit the direct lookup. A dedicated reconcile_variant_mappings routine can re-verify mappings in bulk.

Variant lifecycle on export

Images

Image upload uses Shopify's staged-upload flow: request a staged target, upload the bytes (multipart POST, or a raw PUT for signed targets), attach the media to the product, then poll until Shopify reports the media ready — up to 120 seconds. Replacing a product image deletes the previous media only after the new one is confirmed ready. Variant images are pushed separately, and image work can be skipped per-operation with shopify_skip_image_sync / shopify_skip_image_pull or globally with the Skip product images in bulk sync setting.

Section IXInventory & stock levels

Direction decides the winner

Inventory sync is destructive by design — whichever side is the source overwrites the other. Pull makes Shopify authoritative; Push makes Odoo authoritative. The scheduled cron defaults to Pull.

Push: Odoo → Shopify

For every (warehouse, Shopify location) pair, the connector reads the Odoo available quantity from stock.quant and writes it to Shopify with inventorySetQuantities. Three details matter:

Bulk push deliberately skips per-item inventory activation: each activation call adds SSL round-trip latency and would breach the cron time limit on a large catalogue. Activation belongs to the product-push path, where it happens once per new variant.

Cron safety guards

Inside a scheduled run, inventory push stops early once it exceeds 90 seconds or 300 items (both configurable). It logs what it processed and sends the quantities already collected; the remainder syncs on the next tick. This keeps the cron thread well inside Odoo's limit.

Pull: Shopify → Odoo

Reads Shopify availability for every mapped variant that has an inventory-item GID and overwrites Odoo's on-hand quantity. This is the reverse of push and the default cron direction.

What triggers an inventory push

TriggerBehaviour
Validating a picking After button_validate, affected products are pushed for the locations the moves touched — receipts, deliveries, adjustments and internal transfers alike.
Editing a quant directly A change to quantity on an internal-location quant pushes that product.
Product export Step 3 of the export sequence sets quantities for the exported product.
Scheduled cron Only when Run inventory sync in scheduled cron is enabled; direction follows the cron direction setting.
Sync wizard Explicit push or pull of inventory only.
Reservations are ignored on purpose

Changes to reserved_quantity never trigger a push. Reservation is internal Odoo mechanics — set when an order is confirmed or unreserved — and is not a real stock movement. Reacting to it would send Shopify the pre-deduction quantity, because the quant write fires mid-move before the move lines are fully applied.

For the same reason, picking validation sets shopify_skip_quant_push so the quant hook stays quiet and the picking pushes once, after the move is committed and the final quantity is readable.

Section XOrders: Shopify → Odoo

Selecting orders to import

Incremental runs filter on updated_at:>=… status:any, starting a configurable lookback window (default one hour) before the last successful order sync so orders modified around the boundary are not missed. A full resync uses status:any with no date filter. The first sync for an account is always a full resync.

last_order_sync_date advances to the newest updatedAt actually seen, not to "now", so an interrupted run cannot skip orders.

What an imported order becomes

Odoo fieldShopify source
partner_idResolved or created customer
partner_invoice_id / partner_shipping_idBilling / shipping address as child partners
client_order_refShopify order name, e.g. #1149
noteOrder note
date_ordercreatedAt
warehouse_id / team_idFrom the account configuration
shopify_instance_idThe originating store

Each line records its Shopify provenance on sale.order.line:

Line price comes from originalUnitPriceSet.shopMoney.amount. If Shopify sends no amount, the connector falls back to the Odoo list price plus attribute-value price extras.

Auto-confirmation

An order arriving with financial status PAID is confirmed automatically. If confirmation fails because routes or procurement rules are not configured, the order is left in Draft and a warning is written to shopify.log naming the reason — the import itself still succeeds.

Status tracking

The shopify.order mapping stores the Shopify order name, financial status, fulfillment status, fulfillment GID and whether the order came from a draft order. These surface read-only on the sale order as Shopify Financial Status and Shopify Fulfillment Status, and on the delivery picking as Shopify Order and its fulfillment status.

Duplicate protection

Three layers stop a double import when a webhook and a cron run collide:

  1. a PostgreSQL advisory lock per (instance, order GID) serialises concurrent imports of the same order;
  2. SELECT … FOR UPDATE inside a savepoint detects a concurrent update — if the row moved, the newer data already won and this attempt is skipped;
  3. a unique constraint on the mapping, enforced by the database, catches the remaining race at INSERT time, handled gracefully as "skipped" rather than aborting the transaction.

An order whose updatedAt is not newer than the stored sync_date is skipped as unchanged — unless the Odoo sale order still needs cancelling, in which case it is reprocessed anyway. That exception exists so a missed cancellation webhook is repaired on the next sync.

Discounts, shipping & taxes

Discounts are imported: each line keeps its original price with a discount % equal to the discounts Shopify allocated to it, order-level codes included. Each Shopify shipping line becomes a delivery line at the price paid. Shopify's own tax lines are not imported; Odoo computes taxes from your product and fiscal position settings, and shipping lines are added without Odoo tax on top.

Section XIDraft orders & quotations

Shopify draft orders → Odoo quotations

Open Shopify draft orders (status:open) are imported as draft Odoo sale orders, with their mapping flagged shopify_is_draft_order. A computed order-type label distinguishes them from real orders in the list.

When a draft order is completed in Shopify it becomes a real order with a new GID and name. The connector promotes the existing mapping: it re-points it at the real order GID and clears its sync date, so the following orders/create renames it from D1234 to #1149 and no duplicate Odoo order appears.

Odoo quotations → Shopify draft orders

A confirmed sale order is exported to Shopify as a draft order, provided:

shopify_instance_id is set automatically on import; for an Odoo-originated order you choose the target store yourself. Export builds line items from the stored variant mappings, plus customer and address payloads derived from the Odoo partner. Export failures are logged as warnings and never block action_confirm; the bulk and two-way list actions name each order Shopify rejected.

Sync to Shopify is also available as a bulk action on the sale order list, and Shopify Two-Way Order Sync pushes all visible confirmed orders and then pulls from every connected store, reporting per-direction counts.

Section XIIFulfillment & tracking

Delivery validation creates the Shopify fulfillment

Validating an outgoing picking linked to a Shopify order triggers fulfillmentCreateV2. The connector first queries the order's fulfillment orders and fulfils only those in OPEN or IN_PROGRESS state; if none are open it logs and exits rather than erroring. The carrier tracking reference and carrier name are attached when present, and the returned fulfillment GID is stored on the mapping for later tracking updates.

Fulfillment is also pushed when a picking transitions to done through a write rather than the validate button. Mappings already marked FULFILLED are skipped, so no duplicate fulfillment is created.

Tracking updates in both directions

DirectionBehaviour
Odoo → Shopify Setting or changing carrier_tracking_ref on an already-done outgoing picking calls fulfillmentTrackingInfoUpdateV2 on the stored fulfillment GID. Customer notification is off by default.
Shopify → Odoo An order update carrying tracking info writes the number to the done outgoing picking's carrier_tracking_ref, and matches the tracking company to an Odoo delivery.carrier by name when the picking has no carrier set.

Each direction sets a context flag (shopify_skip_tracking_push) when writing, so an inbound tracking sync cannot bounce straight back out to Shopify.

Only the most recent non-cancelled fulfillment is considered when reading tracking from Shopify, and a write is skipped when the tracking number already matches.

Section XIIIRefunds & cancellations

Refunds become draft credit notes

A refunds/create webhook creates a draft credit note against the order's posted invoice — draft, so your finance team reviews before posting.

SituationResult
Posted invoice exists, refund names line itemsCredit note lines matched to the refunded invoice lines, preserving discount and tax configuration, and linked to the sale order lines so the order lists the credit note
Posted invoice exists, no line items (shipping or adjustment refund)A single amount line built from the refund transactions
No posted invoiceA warning is logged for manual action; nothing is created
Refund already processedSkipped — each credit note is tagged shopify-refund-<id>, making the handler idempotent against webhook redelivery
Order not mappedLogged and ignored

Cancellations and voided orders

The linked Odoo sale order is cancelled when either condition holds:

The second rule catches voided payments that Shopify does not formally cancel — an order nobody paid for and nobody shipped should not stay open in Odoo. Sale orders already in Cancelled or Done are left alone, and a cancellation that Odoo refuses (for example because it is already invoiced) is logged as a warning instead of failing the sync.

Because the cancellation check also runs for orders that look unchanged, a cancellation missed while the connector was offline is repaired on the next sync.

Section XIVCustomers & addresses

Customer resolution order

Imported orders create real Odoo partners. To avoid duplicates, the connector resolves a customer in this order:

  1. Shopify customer GID stored in the standard res.partner.ref field — exact and indexed;
  2. Email, case-insensitive, restricted to non-company partners with no parent. When a match is found and ref is empty, the GID is back-filled so later syncs take the faster path;
  3. Create a new partner from the Shopify name, email and phone.

A per-run cache keyed on GID (or email) prevents repeated lookups when one customer places several orders in the same batch. Where the installed localisation provides separate first_name / last_name fields, they are populated too.

Guest orders

An order with neither a customer GID nor an email is attached to a shared Shopify Customer placeholder partner, created once per company. This keeps guest checkouts importable without generating a partner per anonymous order.

Addresses

Billing and shipping addresses become child partners of type invoice and delivery. An existing child address matching on street, ZIP and city is reused rather than duplicated. Country is matched by ISO code, and state by name or code within that country. When Shopify sends no address, the parent partner is used for both roles.

Section XVScheduled automation

The two scheduled actions

Scheduled actionIntervalWhat it does
Shopify: Auto Sync Connected Accounts 30 minutes Runs a full sync for every active, connected account
Shopify: Retry Errored Products 1 hour Re-attempts product mappings left in error state, each in a fresh transaction

Both are standard ir.cron records — adjust or disable them in Settings → Technical → Scheduled Actions.

What a scheduled sync does

For each connected account, honouring its per-store switches:

  1. Products — incremental, or the next slice of an in-progress full resync;
  2. Orders — incremental, or a full resync on the account's first sync, followed by draft orders;
  3. Inventory — only when Run inventory sync in scheduled cron is enabled, in the configured direction.

Inventory is excluded from the scheduled run by default because it is the slowest phase; enable it once you have confirmed the run finishes comfortably.

Resilience

Order and product runs stop after one batch inside a cron or batch-mode run and resume from the persisted timestamp or cursor on the next tick, keeping every run inside Odoo's cron time limit.

Section XVIManual sync controls

The sync wizard

Shopify → Operations → Sync Now opens a direction-aware wizard — you choose the direction first, then the scope:

FieldOptions
Shopify AccountAny connected account
DirectionPull from Shopify · Push to Shopify
What to SyncAll · Products Only · Orders Only · Inventory Only
Batch ModeProcess one batch and stop — run repeatedly to drain a large queue

Orders cannot be pushed: they originate in Shopify. Selecting Push with Orders Only switches the scope back to All, and the combination is refused with a clear message if forced.

Account buttons

ButtonEffect
Connect to ShopifyStart the OAuth flow (hidden once connected)
Test ConnectionVerify credentials and refresh the shop name
Register WebhooksClear and re-register all webhook topics
Sync LocationsImport Shopify locations as Odoo warehouses and map them
Push Warehouses to ShopifyCreate Shopify locations from unmapped Odoo warehouses
Sync AllRun products, orders and inventory now, honouring the per-store switches
Full Product ResyncQueue a complete catalogue re-fetch for the next cron cycle (confirmation required)
Clean Up Errored ProductsVerify errored mappings against Shopify and archive genuinely deleted products (confirmation required)

The form also carries statistic buttons for mapped Products and Orders, an Error ribbon when the account is in error state, and a per-row Sync button in the account list.

Record-level actions

WhereAction
Product form / listPush to Shopify — explicit store selection
Product formPull from Shopify — re-import this product
Product listSync Shopify Products — push then pull the selection
Sale order listSync to Shopify — export confirmed orders
Sale order listShopify Two-Way Order Sync — push visible orders, then pull
Shopify product mappingImport from Shopify — fetch by GID and reload the form

Section XVIISettings reference

Settings → Shopify Connector exposes the options below. All are stored as ir.config_parameter records under the baseup_shopify. prefix.

Exposed in the settings UI

SettingParameterDefaultEffect
Sync products by defaultdefault_sync_productsOnDefault for new accounts
Sync orders by defaultdefault_sync_ordersOnDefault for new accounts; also gates outbound order export
Sync inventory by defaultdefault_sync_inventoryOnDefault for new accounts
Run inventory sync in scheduled croncron_sync_inventoryOffWhen off, the cron handles only products and orders
Cron inventory directioncron_inventory_directionpullpull = Shopify wins; push = Odoo wins
Auto-push newly created productsauto_push_new_productsOffExport new templates automatically (single-store only)
Auto-update products on saveauto_push_product_updatesOffRe-export linked products when watched fields change
Sync batch sizesync_batch_size100Shared batch size for product, order and inventory flows
Skip product images in bulk syncproduct_sync_skip_imagesOffLarge speed gain on big catalogues
Order sync lookback windoworder_sync_lookback_hours1Hours of overlap before the last sync date

System parameters (no UI field)

ParameterDefaultEffect
baseup_shopify.webhook_base_url—Public HTTPS base URL for webhook and OAuth callbacks; takes precedence over web.base.url
baseup_shopify.cron_inventory_max_seconds90Time budget for inventory push inside a cron run
baseup_shopify.cron_inventory_max_items300Item budget for inventory push inside a cron run
baseup_shopify.order_sync_commit_every100Legacy order batch size; superseded by sync_batch_size
baseup_shopify.inventory_sync_chunk_size100Legacy inventory chunk size; superseded by sync_batch_size
Switches stay off

Odoo 20 stores an unticked setting as a real False value instead of deleting it, so a default-on box you switch off stays off. The connector reads every parameter with Odoo 20's typed getters.

Section XVIIILogs & troubleshooting

The Shopify log

Shopify → Operations → Logs lists shopify.log records, newest first. Each entry records the instance, type (Success Info Warning Error), the operation, the affected Odoo model and record, the Shopify ID or GID, and a message.

Log writing is itself defensive: if the transaction is already in a bad state the failure to log is caught and reported to the server log rather than masking the original error.

Common situations

SymptomCause and resolution
"Webhook URL must be a public HTTPS URL" The effective base URL is not publicly reachable. Set baseup_shopify.webhook_base_url to your public HTTPS domain and retry.
"No public base URL is configured" Neither baseup_shopify.webhook_base_url nor web.base.url is set — OAuth cannot build a redirect URI.
"Shopify authentication failed" and the account flips to Error The access token is invalid or revoked (HTTP 401). Reconnect through OAuth.
"Shopify rate limit reached" HTTP 429. Reduce Sync batch size or lengthen the cron interval.
Orders import but stay in Draft Auto-confirm failed on routes or procurement rules; the reason is in the log. Fix the product's route configuration and confirm manually.
Product mappings stuck in Error Wait for the hourly retry cron, then use Clean Up Errored Products to archive any whose Shopify product was deleted.
Inventory sync never finishes in the cron It is stopping at the 90-second / 300-item guard by design and resuming next tick. Raise the guards, or run inventory from the wizard instead.
A new product did not auto-push Either auto-push is off, or more than one store is connected — the multi-store guard skips it. Use Push to Shopify.
An order line resolved to the wrong variant A stale mapping from positional fallback. The connector detects and corrects this on the next import, logging a warning with both variants.
"Shopify SSL verification failed" The server's CA bundle cannot verify Shopify. The connector pins requests' CA bundle explicitly; check the host's certificate store.

Concurrency, in one place

Much of the module's complexity exists to survive webhooks and crons touching the same rows simultaneously under REPEATABLE READ. The techniques used:

TechniqueWhere
Advisory transaction locksPer order GID on import; per product on export
FOR UPDATE SKIP LOCKEDWebhook marker writes — back off instead of blocking
Raw SAVEPOINT instead of the ORM helperTimestamp flushes, order locks, webhook markers
Full rollback between retriesWebhook dispatch — a savepoint rollback keeps the stale snapshot
Bounded retries with backoffWebhooks (4 attempts), cron account sync (3 attempts), product template writes (6 attempts)
Unique constraints as a final guardOrder and product mappings, caught as "skipped"
No-op write detectionMapping updates skip writes when nothing changed

Section XIXGuided tours

The module ships four interactive walkthroughs that drive the real UI. Start one from Settings → Technical → Tours, or let the onboarding tour run for a new user. All four follow the Odoo 20 web client and run end to end in the module's automated test suite.

TourCovers
shopify_onboardingCreating the account, entering credentials, choosing a warehouse, connecting via OAuth, registering webhooks, syncing locations
shopify_first_product_pushPushing a first product to Shopify
shopify_multi_warehouse_setupMapping several Shopify locations to Odoo warehouses
shopify_first_order_reviewReviewing an imported order and its Shopify status fields

Section XXAccess & security

Group permissions

Model groupSales ManagerInternal User
Accounts, instances, mappings, locations, logsRead, write, create, deleteRead only
Sync wizard, push wizardFullFull transient

The Shopify menu itself is restricted to sales_team.group_sale_manager. Internal users keep read access so Shopify status fields remain visible on products, orders and pickings they already work with.

Credential handling

Section XXINew in 20.0 & upgrading from 19.0

Version 20.0.1.0.0 is the Odoo 20 edition. Everything below is new or changed compared with 19.0.2.0.0.

What changed

AreaChange
PlatformOdoo 20 (Python 3.12+, PostgreSQL 16+); stock_delivery is a declared dependency
SecurityThe OAuth callback verifies Shopify's signature and the shop; only valid scopes are requested, including fulfillment orders
OrdersDiscounts and shipping are imported, so the Odoo total matches Shopify
Draft ordersA completed draft order takes the real order number
RefundsCredit notes are linked to the sale order lines
Data integrityUnique pairings for products, variants, orders and locations are enforced by the database
FeedbackFailed pushes and exports are reported as failures; Test Connection keeps the Error state
AutomationOne failing account no longer undoes others; the inventory item limit sends what it collected; a failing product no longer stalls a full resync
Settings"Sync … by default" applies to new accounts; quotations respect "Sync orders by default"
InterfaceOdoo 20 icons and access rules; the onboarding tours follow the Odoo 20 web client; the product list action opens the push wizard

Upgrade checklist

  1. Step 1Back up the database and filestore, and test on a copy first.
  2. Step 2Upgrade the database to Odoo 20 with Odoo's upgrade service.
  3. Step 3Replace the module folder with the 20.0 version and run -u baseup_shopify. A pre-migration script removes duplicate mappings, keeping the newest, before the unique constraints are added.
  4. Step 4In the Shopify app, replace write_webhooks with the two fulfillment-order scopes, then click Connect to Shopify again to grant them.
  5. Step 5Click Register Webhooks if the public address changed, then place a test order and validate its delivery.

Section XXIILimits & licensing

Known limits

Requirements

RequirementDetail
Odoo20.0 (Python 3.12+, PostgreSQL 16+), with sale_management, website_sale, stock, product, account, delivery and stock_delivery installed
ShopifyA store plus a custom app from the Shopify Dev Dashboard with the scopes in Section III
NetworkingA public HTTPS URL for OAuth redirects and webhook delivery; outbound HTTPS to Shopify
Pythonrequests (already an Odoo dependency)
Scheduled actionsOdoo cron enabled for automatic syncing

Licensing & support

ItemDetail
Modulebaseup_shopify — Shopify Connector
Version20.0.1.0.0
LicenseOdoo Proprietary License v1.0 (OPL-1)
PriceUSD 630.00
AuthorBaseUp Labs — baseuplabs.com
Supportsupport@baseuplabs.com