Inconsistence in receiving transaction_id & customer_id after checkount_completed analytics event

I’m seeing inconsistent data coming from Shopify Web Pixels using analytics.subscribe("checkout_completed"), and I’m trying to understand whether this is expected behavior or an implementation issue.

Setup:

  • Shopify Web Pixel

  • Subscribed to checkout_completed

  • Sending purchase events to GA4

  • Mapping:

    • transaction_idevent.data.checkout.order.id

    • customer_idevent.data.checkout.customer.id

Issue:
Both transaction_id and customer_id are inconsistently missing in some events (but always never both).

In GA4 BQ export, I see:

  • Valid purchase events with both fields populated

  • Some events where:

    • transaction_id is null / (not set)

    • customer_id is also null

    • but other fields (e.g. session or Shopify user identifiers) are still present

  • In some cases, for the same session/user:

    • one purchase event is fully populated

    • another purchase event is missing these key fields

I have already checked if this is related to consent, but I see it happening when the user is giving full consent but also when no consent is given. It seems to be all over the place.

Questions:

  1. Is checkout_completed guaranteed to always contain a fully populated checkout.order and checkout.customer object?

  2. Are there known cases where this event fires with partial or delayed data?

  3. What is the recommended way to ensure consistent availability of transaction_id and customer_id?

  4. Are there known differences in payload structure depending on checkout flow (e.g. Shop Pay, accelerated checkout, etc.)?

Right now it’s difficult to rely on this event for clean purchase tracking due to inconsistent identifiers.

Any insights or confirmations would be appreciated.

For completeness, I am using exactly this script for this specific event within customer events:

For triggering I am using a trigger group that waits until both checkout_completed and window loaded are available due to having consent available. But this shouldn’t be the reason since I also saw the described issue appearing when directly using the checkout_completed event.

analytics.subscribe('checkout_completed', (event) => {

const productLines = event.data?.checkout?.lineItems || [];
console.log(productLines)

const deliveryOptions = event.data?.checkout?.delivery?.selectedDeliveryOptions?.[0]
const transactionOptions = event.data?.checkout?.transactions?.[0]

const checkoutCompletedPayload = {
  ...buildBasePayload(event, 'checkout_completed'), //defined earlier in script
  ecommerce: {
    email_marketing: event.data?.checkout?.buyerAcceptsEmailMarketing,
    sms_marketing: event.data?.checkout?.buyerAcceptsSmsMarketing,
    checkout_token: event.data?.checkout?.token,
    currency: event.data?.checkout?.currencyCode,
    discount: event.data?.checkout?.discountsAmount?.amount,
    value: event.data?.checkout?.subtotalPrice?.amount,
    value_incl: event.data?.checkout?.totalPrice?.amount,
    shipping: event.data?.checkout?.shippingLine?.price?.amount,
    tax: event.data?.checkout?.totalTax?.amount,
    country: event.data?.checkout?.localization?.country?.isoCode,
    language: event.data?.checkout?.localization?.language?.isoCode,
    items: productLines.map((line, index) => ({
      item_id: line?.variant?.product?.id,
      item_name: line?.variant?.product?.title,
      item_name_en: line?.variant?.product?.untranslatedTitle,
      affiliation: line?.variant?.product?.vendor, 
      item_category: line?.variant?.product?.type,
      original_price: line?.variant?.price?.amount,
      price: line?.finalLinePrice?.amount ?? line?.variant?.price?.amount,
      quantity: line?.quantity,
      index: index,
      position: index + 1,
      item_variant_sku: line?.variant?.sku,
      item_variant_id: line?.variant?.id,
      item_variant: line?.variant?.title, 
      item_variant_en: line?.variant?.untranslatedTitle,
    })),
    transaction_id: event.data?.checkout?.order?.id, 
    shopify_customer_id: event.data?.checkout?.order?.customer?.id,
    first_order: event.data?.checkout?.order?.customer?.isFirstOrder,
    customer_type: event.data?.checkout?.order?.customer?.isFirstOrder ? 'new' : 'returning',
    delivery_cost: deliveryOptions?.cost?.amount,
    delivery_description: deliveryOptions?.description,
    delivery_handle: deliveryOptions?.handle,
    delivery_title: deliveryOptions?.title,
    delivery_type: deliveryOptions?.type,
    payment_gateway: transactionOptions?.gateway,
    payment_method_name: transactionOptions?.paymentMethod?.name,
    payment_method_type: transactionOptions?.paymentMethod?.type,
  }
}

pushToDataLayer(checkoutCompletedPayload, 'checkout_completed'); //for debugging

});

Hey J_B, this is a pretty well known inconsistency with the Web Pixels API and it trips up a lot of teams tracking purchases through GA4.

The order object inside checkout_completed only gets fully populated after payment processing completes, and the timing on that varies depending on the checkout method. Shop Pay and accelerated flows sometimes resolve payment asynchronously, which means the event can fire before order.id and order.customer.id have actually been written into the payload. For guest checkouts specifically, customer.id lives at event.data.checkout.order.customer.id and wont be available through init.data.customer since that stays null for the entire session if the buyer never logged in ( checkout_completed ). Same story with transactions[], certain payment methods like credit cards populate that array while others dont, and theres no documented guarantee on which fields will be present across all gateways.

The most reliable workaround I’ve seen is adding null checks and using checkout.token as a temporary identifier when order.id comes back empty, then reconciling against your order records server side afterward. Working with a bunch of Shopify setups at Stacksync, this partial payload pattern on checkout_completed comes up more than you’d expect and the token fallback tends to be the safest bet until Shopify addresses it more formally. Also worth confirming your store has fully migrated to Checkout Extensibility since fields like buyerAcceptsEmailMarketing, delivery cost details, and the paymentMethod properties on transactions are all gated behind that upgrade and will return null on stores that havent switched yet.

Hope this helps.

2 Likes

Hi Ruben, Thanks for your clarification, than it definitely makes sense. I am not that familiar with Shopify but this helps a lot. Do you by any chance know a source where I am able to find more of these inconsistencies to widen my knowledge on Shopify Tracking and the downsides of them in comparison with “normal” tracking setups?