Shopify Odoo Integration

Shopify Odoo fulfillment and tracking sync: end-to-end architecture

Three systems — Shopify, Odoo, and the carrier — all want to be the source of truth for fulfillment. This guide covers the architecture for syncing fulfillment status and tracking numbers between Shopify and Odoo without data loss or duplication.

Three systems, one shipment, zero agreement

A warehouse manager confirms a delivery in Odoo at 11:03 AM. At 11:04 AM, UPS picks up the package and generates a tracking number in their system. At 11:08 AM, the carrier integration writes the tracking number back to Odoo's stock picking. At 11:15 AM, the connector is supposed to push the tracking number to Shopify and create a fulfillment. At 11:22 AM, the customer gets their shipping confirmation email.

That is the happy path. In practice, a dozen things can go wrong in the 19 minutes between confirmation and customer email: the carrier integration may fail, the Odoo event hook may not fire, the connector may be in the middle of a retry cycle for a different order, or Shopify may rate-limit the fulfillment creation API call.

Fulfillment sync is the most operationally sensitive part of a Shopify Odoo connector. Unlike inventory sync (where a slightly stale number is acceptable for a few minutes) or product sync (where changes are infrequent), fulfillment sync has a direct customer experience impact. A delayed or failed fulfillment sync means a customer who doesn't get their shipping notification, or worse, a Shopify order stuck in "unfulfilled" status while the physical package is already out for delivery.

This guide covers the complete architecture for building fulfillment and tracking sync that is reliable, idempotent, and operationally observable.

---

The fulfillment problem: three sources of truth

Shopify's view

Shopify tracks fulfillment at the order level using Fulfillment objects. Each fulfillment has:

  • id — unique identifier
  • order_id — the parent order
  • line_items — which order lines (and quantities) are in this fulfillment
  • tracking_number — the carrier tracking number (single value in the basic API)
  • tracking_numbers — array for multiple tracking numbers
  • tracking_company — the carrier name (UPS, USPS, FedEx, etc.)
  • tracking_url — direct tracking URL
  • statuspending, open, success, cancelled, error, failure
  • location_id — which Shopify location fulfilled the order

Shopify considers an order "fulfilled" when all line items have a fulfillment in success status. Partial fulfillment is when some line items are fulfilled and others are not.

Odoo's view

Odoo tracks fulfillment via stock.picking records (delivery orders). Each picking has:

  • name — the delivery order number (e.g., WH/OUT/00042)
  • sale_id — link back to the sale order (and via that, to the Shopify order)
  • move_ids — the stock moves, each linked to a product and quantity
  • statedraft, waiting, confirmed, assigned, done, cancel
  • carrier_id — the carrier record (many2one to delivery.carrier)
  • carrier_tracking_ref — the tracking number
  • date_done — when the delivery was confirmed

A sale order in Odoo may generate multiple pickings (if multi-step routes are configured — e.g., pick → pack → ship). Only the final "outgoing" picking (the one that goes to the customer) should trigger a Shopify fulfillment. Intermediate pickings (pick, pack) are internal movements.

The carrier's view

The carrier (UPS, FedEx, USPS, DHL, etc.) generates a tracking number when a label is created. The tracking number may be generated:

  • By Odoo's carrier integration (direct carrier API from Odoo) — label is created in Odoo before physical pickup
  • By the 3PL's own WMS system — label is created outside both Shopify and Odoo, then imported into Odoo via EDI or API
  • By a separate shipping platform (ShipStation, Shippo, EasyPost) — label is created there and the tracking number is pushed to both Shopify and Odoo separately

The timing of when the tracking number is available relative to when Odoo's picking is confirmed is not fixed. In some operations, the tracking number is generated hours before the physical shipment. In others, it's generated at the moment of handoff to the carrier.

---

Shopify fulfillment objects: anatomy and status transitions

The fulfillment lifecycle in Shopify:

`` (no fulfillment) → pending → open → success ↘ cancelled ↘ failure ``

When you create a fulfillment via the API, it starts in pending and typically transitions to success quickly (usually within seconds, asynchronously on Shopify's side). For most connector implementations, you create the fulfillment and then verify its status with a follow-up read.

Key API considerations:

  • Fulfillment creation requires fulfillment_order_id in modern Shopify API (2022-07+). The older order-based fulfillment API (POST /orders/{id}/fulfillments) is deprecated. The new flow requires fetching fulfillment_orders first, then creating a fulfillment against the fulfillment order.
  • Line item quantities must match what's in the fulfillment order. If Shopify's fulfillment order shows 10 units assigned to your warehouse location, you cannot fulfill 8 without explicitly marking 2 as not fulfilled in this shipment.
  • Tracking number updates require a different API endpoint. If you create a fulfillment without a tracking number and then want to add one, use POST /fulfillments/{id}/update_tracking. Do not cancel and recreate.

The fulfillment order flow

``` GET /orders/{order_id}/fulfillment_orders → Returns array of fulfillment_order objects, one per location

POST /fulfillments { "fulfillment": { "line_items_by_fulfillment_order": [ { "fulfillment_order_id": 12345, "fulfillment_order_line_items": [ {"id": 67890, "quantity": 5} ] } ], "tracking_info": { "number": "1Z999AA10123456784", "company": "UPS", "url": "https://www.ups.com/track?tracknum=1Z999..." }, "notify_customer": true } } ```

---

Odoo deliveries: stock.picking state machine

The stock.picking state machine:

`` draft → confirmed → assigned → done ↘ cancel ``

  • draft — picking created but not yet ready
  • confirmed — picking confirmed, demand is registered
  • assigned — stock reserved, ready to pick
  • done — picking validated (physically shipped)
  • cancel — picking cancelled

For connector purposes, the trigger for creating a Shopify fulfillment is the transition to done. This is when date_done is set and carrier_tracking_ref should be populated (if carrier integration is working correctly).

However, not all done pickings should create a Shopify fulfillment. A connector must filter for only customer-facing outgoing pickings:

``python def is_outgoing_to_customer(picking): return ( picking.picking_type_code == 'outgoing' and picking.location_dest_id.usage == 'customer' and picking.sale_id is not None ) ``

Internal transfers, vendor returns, and scrap moves also create pickings in Odoo and must be excluded.

---

Mapping patterns: two sync directions

Direction A: Odoo delivery confirmation → Shopify fulfillment (most common)

This is the standard pattern for merchants who manage fulfillment in Odoo (or whose 3PL manages it in Odoo).

1. Odoo stock.picking transitions to done 2. Connector detects this event (via polling or webhook) 3. Connector reads carrier_tracking_ref and carrier_id from the picking 4. Connector looks up the linked sale.order and then the original Shopify order ID (stored in a custom field or in the sale order's client_order_ref) 5. Connector fetches the Shopify fulfillment order for that order 6. Connector creates the Shopify fulfillment with tracking info 7. Connector marks the internal mapping record as fulfilled to ensure idempotency

Challenges with Direction A:

  • The carrier_tracking_ref may not be populated at the time the picking goes to done. The carrier integration may write it seconds or minutes later.
  • For 3PL operations, the picking may be confirmed in Odoo via EDI import, and the tracking number arrives in a separate EDI message hours later.

The solution is a two-phase approach:

  • Phase 1: When picking goes to done, create a Shopify fulfillment without tracking number (or in pending state).
  • Phase 2: When carrier_tracking_ref is written to the picking, update the Shopify fulfillment with tracking via the update_tracking endpoint.

Direction B: Shopify fulfillment → Odoo delivery confirmation (less common)

Some merchants manage fulfillment in Shopify (e.g., using Shopify Shipping for label generation) and want the fulfillment status reflected in Odoo.

In this direction: 1. A Shopify fulfillment is created (manually or via a shipping app) 2. A fulfillments/create webhook fires 3. Connector receives the webhook and finds the corresponding Odoo stock.picking 4. Connector validates the picking (calls stock.picking.button_validate() in Odoo) 5. Connector writes the tracking number to carrier_tracking_ref

This direction is simpler because Shopify's fulfillment event is clean and well-documented. The complexity is in finding the right Odoo picking and calling the validation correctly.

---

Tracking number flow: where carrier integrations slot in

The tracking number travels from the carrier's system into the connector through one of several paths:

Path 1: Odoo-native carrier integration

Odoo has built-in carrier API integrations (via the delivery module and carrier-specific modules like delivery_ups, delivery_fedex). When a delivery is validated in Odoo, the carrier module calls the carrier API, generates a label, and writes the tracking number to carrier_tracking_ref on the picking.

For connectors using this path, the correct event to listen for is not the picking's done state but specifically a write to carrier_tracking_ref. Some implementations use a scheduled action that queries:

```python

Find done pickings with tracking numbers that haven't been synced to Shopify

pickings = client.search_read( 'stock.picking', [ ['state', '=', 'done'], ['carrier_tracking_ref', '!=', False], ['shopify_fulfillment_synced', '=', False], # custom field ['sale_id', '!=', False] ], fields=['id', 'carrier_tracking_ref', 'carrier_id', 'sale_id', 'date_done'] ) ```

Path 2: External shipping platform

If the merchant uses a platform like ShipStation, Shippo, or EasyPost for label generation, the tracking number exists in that platform before it exists in either Shopify or Odoo. The typical flow:

1. Shipping platform generates the label 2. Shipping platform pushes tracking to Shopify via its own Shopify integration 3. Shipping platform pushes tracking to Odoo via its own Odoo integration (or a connector)

In this case, the Shopify Odoo connector should NOT create Shopify fulfillments — that responsibility belongs to the shipping platform integration. The connector's fulfillment role is limited to updating the Odoo picking state when Shopify fulfillment is confirmed.

Clearly document which system creates the Shopify fulfillment to avoid double-fulfillment bugs.

Path 3: 3PL EDI feeds

Many 3PL providers send daily or batch ASN (Advanced Shipping Notice) files in EDI format. These files contain the tracking numbers for all shipments that left the 3PL warehouse that day. Odoo can ingest these via EDI imports, writing tracking numbers to the relevant pickings.

For this path, the connector needs to watch for batch tracking number updates (many pickings getting carrier_tracking_ref written in quick succession) and process them efficiently without creating a flood of individual Shopify API calls. Batching is important here.

---

Partial fulfillments: split shipments and line-item-level tracking

Partial fulfillment occurs when:

  • An order has multiple line items and not all can be shipped at once
  • An order has multiple units of one line item and they ship in separate boxes
  • An order is split across multiple warehouses

In Shopify, each partial shipment is a separate Fulfillment object, each with its own line_items array specifying which lines (and quantities) are in that shipment.

In Odoo, each partial shipment is a separate stock.picking (Odoo creates a backorder for the unfulfilled quantity).

The connector must handle this correctly:

```python def sync_partial_fulfillment(odoo_picking, shopify_order_id, client):

Get the fulfillment orders for this Shopify order

fulfillment_orders = shopify_get_fulfillment_orders(shopify_order_id)

Find which Shopify line items correspond to this Odoo picking's moves

line_items_to_fulfill = [] for move in odoo_picking.move_ids: shopify_line_item_id = get_shopify_line_item_id(move.sale_line_id) line_items_to_fulfill.append({ 'id': shopify_line_item_id, 'quantity': move.quantity_done })

Build the fulfillment request with only the lines in this shipment

fulfillment_request = build_partial_fulfillment_request( fulfillment_orders, line_items_to_fulfill, odoo_picking.carrier_tracking_ref )

return shopify_create_fulfillment(fulfillment_request) ```

The critical invariant: the sum of quantities across all partial Shopify fulfillments must equal the total ordered quantity for each line item. Never create a fulfillment that exceeds the ordered quantity. Always check the remaining unfulfilled quantity from the Shopify fulfillment order before creating a new fulfillment.

---

Multi-warehouse fulfillment: which warehouse fulfilled what

When an order is fulfilled from multiple warehouses, each warehouse generates its own stock.picking in Odoo. Each picking must create a separate Shopify fulfillment with its own tracking number.

The connector must store the warehouse-to-Shopify-location mapping:

``json { "odoo_warehouse_id": 1, "odoo_warehouse_code": "WH_NJ", "shopify_location_id": "12345" } ``

When creating the Shopify fulfillment, the location_id must match the Shopify location that corresponds to the fulfilling Odoo warehouse. If the wrong Shopify location is used, Shopify's inventory levels at the correct location will not be decremented.

---

Returns and reverse logistics

A return in Shopify creates a Refund with refund_line_items that have a restock_type. If restock_type = 'return', the items are being sent back to a Shopify location. If restock_type = 'no_restock', they are not being returned to inventory.

In Odoo, a return creates a stock.picking of type incoming with the return picking pointing back to the customer location. When the return picking is validated, the items go back into Odoo's stock.

The connector must handle the reverse logistics flow:

1. Shopify refund with restock_type = 'return' arrives via webhook 2. Connector creates an Odoo return picking (via stock.return.picking wizard or direct picking creation) 3. Warehouse receives the physical return and validates the return picking in Odoo 4. Connector detects the validated return picking and updates Shopify inventory (+quantity for the returned items)

If restock_type = 'no_restock', only a credit note needs to be created in Odoo — no inventory movement.

---

Idempotency: why re-running a fulfillment sync must never duplicate

Fulfillment sync must be idempotent. Running the same sync operation twice must produce the same result as running it once.

Why this matters: network failures, application restarts, and retry logic mean that a fulfillment sync operation may be attempted multiple times for the same Odoo picking. Without idempotency controls, each attempt creates a new Shopify fulfillment, resulting in the customer getting multiple shipping confirmation emails and Shopify showing duplicate fulfillments on the order.

The idempotency pattern:

1. Before creating a Shopify fulfillment, check if one already exists for this Odoo picking. 2. Store a mapping between odoo_picking_id and shopify_fulfillment_id in your connector's database. 3. If a mapping exists, skip the creation step (or update tracking if that's what changed). 4. If the Shopify fulfillment creation fails, do not delete the mapping entry — retry will check the mapping before creating.

```python class FulfillmentSyncRecord: odoo_picking_id: int shopify_order_id: str shopify_fulfillment_id: str | None # None = not yet created status: str # 'pending', 'created', 'tracking_updated', 'failed' created_at: datetime last_attempt: datetime retry_count: int

def sync_fulfillment(picking_id): record = db.get_sync_record(odoo_picking_id=picking_id)

if record and record.status == 'created':

Already synced - check if tracking number changed

current_tracking = get_odoo_tracking(picking_id) if current_tracking != record.last_tracking: update_shopify_tracking(record.shopify_fulfillment_id, current_tracking) return

Create new fulfillment

fulfillment_id = create_shopify_fulfillment(picking_id) db.save_sync_record(picking_id, fulfillment_id, status='created') ```

---

Webhook + scheduled sync redundancy

No production fulfillment sync should rely solely on webhooks. Webhooks are best-effort: they can fail, be delayed, or be delivered out of order.

The reliable architecture uses both:

Webhooks for low-latency updates:

  • Subscribe to fulfillments/create in Shopify for Direction B (Shopify → Odoo)
  • Implement a change detection listener in Odoo for Direction A (Odoo → Shopify) — either a database trigger, an ir.rule-based notification, or a polling job that queries for recently-done pickings

Scheduled sync as the backstop:

  • Every 15 minutes: query Odoo for pickings that went to done in the last 20 minutes (slightly overlapping with the previous window) and have not been synced
  • Every hour: query Shopify for orders that are still "unfulfilled" but have Odoo pickings in done state
  • Daily reconciliation: compare all fulfilled Shopify orders against corresponding Odoo pickings; flag any discrepancies for manual review

The 15-minute scheduled sync catches any events that the real-time detection missed. The daily reconciliation catches long-tail issues.

---

Edge cases: carrier label changes, manual overrides, and customer-driven edits

Carrier label changes

Some carrier integrations allow re-generating a label (e.g., if the original package was damaged). This writes a new tracking number to Odoo's carrier_tracking_ref. The connector must detect this and update the Shopify fulfillment's tracking number via POST /fulfillments/{id}/update_tracking. Do not cancel the fulfillment and create a new one — this triggers another customer notification email.

Manual fulfillment overrides

Some merchants create Shopify fulfillments manually (via the Shopify admin) for orders that were processed outside Odoo. When the connector later encounters the Odoo picking for these orders, it must check whether a Shopify fulfillment already exists before creating another one.

The check:

``python def has_existing_shopify_fulfillment(shopify_order_id, line_item_ids): fulfillments = shopify_get_fulfillments(shopify_order_id) for fulfillment in fulfillments: fulfilled_items = {item['id'] for item in fulfillment['line_items']} if fulfilled_items.intersection(set(line_item_ids)): return True return False ``

Customer-driven fulfillment edits

If a customer contacts Shopify support and an agent edits a fulfillment (changes the tracking number, cancels a partial fulfillment), the connector should not override that edit on the next sync cycle. Track the last-known Shopify fulfillment state in your mapping table and only update Shopify if the Odoo data has changed since the last known state — not just because the data differs from Shopify's current state.

---

Summary

Reliable Shopify Odoo fulfillment and tracking sync requires:

1. Clear direction rules — decide whether Odoo confirmation drives Shopify, or Shopify drives Odoo confirmation, for each fulfillment scenario 2. Idempotency at every step — store fulfillment mapping records and check before creating 3. Two-phase tracking — create the fulfillment first, add tracking when available 4. Partial fulfillment support — map Odoo moves to Shopify fulfillment order line items 5. Webhook + scheduled redundancy — never rely on webhooks alone 6. Return logistics handling — connect Shopify refund restock events to Odoo return pickings

Synco Connector implements all of these patterns with configurable direction rules, built-in idempotency, and 15-minute scheduled reconciliation as a backstop.

Install from the Shopify App Store or visit our fulfillment tracking sync guide and order sync reference for configuration details.

Keep reading

Related guides