> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sourcemedium.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Capturing & Backfilling UTM Attribution via Order Attributes

> How to backfill historical orders with UTM attribution data and capture UTMs for future orders

<Info>
  **Audience**: This guide is for developers and technical teams who need to add UTM attribution data to Shopify orders—either backfilling historical orders or capturing UTMs for future orders.
</Info>

## Overview

SourceMedium extracts UTM attribution data from Shopify order-level `customAttributes`. This guide covers two scenarios:

1. **Backfilling existing orders** — You have attribution data (from spreadsheets, surveys, external tools) and want to write it to historical orders.
2. **Capturing UTMs going forward** — You want to automatically capture UTM parameters at checkout for future orders.

<CardGroup cols={2}>
  <Card title="Backfill Historical Orders" icon="clock-rotate-left" href="#backfilling-historical-orders">
    Use the Shopify Admin API to add attribution data to existing orders
  </Card>

  <Card title="Capture Future UTMs" icon="forward" href="#capturing-utms-for-future-orders">
    Use Checkout UI Extensions to automatically capture UTMs at checkout
  </Card>
</CardGroup>

***

## Supported Keys (Normalized)

SourceMedium extracts a specific allowlist of keys from order-level `customAttributes`.

Keys are **normalized** before matching (snake\_case / camelCase / delimiter / case agnostic):

* `utm_source`, `utmSource`, `UTM_SOURCE`, `utm-source` → treated as the same key
* `sm_utmParams`, `smUtmParams` → treated as the same key
* `GE_utmParams`, `ge_utm_params` → treated as the same key

<Note>
  To reduce collisions with other checkout apps, prefer the `sm_utm_*` / `sm_utmParams` keys for explicit overrides. Standard `utm_*` keys are also supported.
</Note>

| Key                                                                                               | Description                              | Example                            |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------- |
| `sm_utm_source`, `sm_utm_medium`, `sm_utm_campaign`, `sm_utm_content`, `sm_utm_term`, `sm_utm_id` | SourceMedium override UTMs (recommended) | `sm_utm_source=facebook`           |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `utm_id`                   | Standard UTMs                            | `utm_campaign=summer_sale_2025`    |
| `sm_utmParams`, `utmParams`, `GE_utmParams`                                                       | Aggregate UTM query string (parsed)      | `utm_source=google&utm_medium=cpc` |
| `sm_referrer`, `referrer`                                                                         | Referring URL                            | `https://blog.example.com/review`  |

### Click IDs (fallback inference)

Click IDs are processed but **not stored as raw values**. If no explicit `utm_source` is present, their presence infers a channel-level `utm_source` (fallback-only).

| Click ID    | Inferred `utm_source` |
| ----------- | --------------------- |
| `scclid`    | `snapchat`            |
| `irclickid` | `impact`              |
| `msclkid`   | `microsoft`           |
| `ttclid`    | `tiktok`              |
| `fbclid`    | `meta`                |
| `gclid`     | `google`              |

If multiple click IDs exist, the system prioritizes: `scclid` > `irclickid` > `msclkid` > `ttclid` > `fbclid` > `gclid`.

### Conflict resolution (deterministic)

If you provide conflicting values (e.g., both `sm_utm_source` and `utm_source`, or both direct keys and `utmParams`), SourceMedium resolves each final field with a deterministic waterfall:

* **UTM fields**: direct `sm_utm_*` → direct `utm_*` → parsed from `sm_utmParams` → parsed from `utmParams` → parsed from `GE_utmParams`
* **`utm_source` only**: if still missing, infer from click IDs (`scclid` → `irclickid` → `msclkid` → `ttclid` → `fbclid` → `gclid`)
* **Referrer**: `sm_referrer` → `referrer`

If the same normalized key appears multiple times at the same level (e.g., `utm_source` and `UTM_SOURCE`), SourceMedium de-dupes deterministically using `MAX()` (lexicographically largest value). To avoid surprises, only set each key once.

***

## Backfilling Historical Orders

If you have attribution data for existing orders (e.g., from post-purchase surveys, manual tracking, or external tools), you can write it to Shopify orders so SourceMedium can extract it.

### How It Works

```mermaid theme={null}
flowchart LR
    A[Your attribution data<br/>CSV/spreadsheet/database] --> B[Update orders via<br/>API or Shopify Flow]
    B --> C[customAttributes added<br/>to existing orders]
    C --> D[SourceMedium extracts<br/>on next sync]
    D --> E[Attribution appears<br/>in dashboards]
```

### Available Methods

| Method               | Best For                                      | Technical Skill Required |
| -------------------- | --------------------------------------------- | ------------------------ |
| **Shopify Flow**     | Small batches, no-code users, Shopify Plus    | Low (no coding)          |
| **Admin API Script** | Large bulk backfills, automation              | Medium (Python/Node.js)  |
| **Third-Party Apps** | Limited - most don't support order attributes | Varies                   |

<Note>
  **No CSV import available**: Unlike products, Shopify does not support CSV import for order attributes. Matrixify and similar bulk import tools also do not support `customAttributes` on orders.
</Note>

***

### Option 1: Shopify Flow (No-Code)

If you have Shopify Plus, you can use **Shopify Flow** with the "Send Admin API request" action. This is ideal for smaller batches or when you want to trigger updates based on conditions.

<Steps>
  <Step title="Create a Flow workflow">
    Go to **Settings → Flow** and create a new workflow. Use a trigger like "Order created" for new orders, or tag orders you want to backfill and trigger on "Order tags added".
  </Step>

  <Step title="Add 'Send Admin API request' action">
    Select **GraphQL Admin API** and choose the **orderUpdate** mutation.
  </Step>

  <Step title="Configure the mutation">
    Use this template to preserve existing attributes while adding new ones:

    **Mutation:**

    ```graphql theme={null}
    mutation orderUpdate($input: OrderInput!) {
      orderUpdate(input: $input) {
        order { id }
        userErrors { field message }
      }
    }
    ```

    **Variables (JSON with Liquid):**

    ```liquid theme={null}
    {
      "input": {
        "id": "{{ order.id }}",
        "customAttributes": [
          {%- for attr in order.customAttributes -%}
            { "key": "{{ attr.key }}", "value": "{{ attr.value }}" }{% unless forloop.last %},{% endunless %}
          {%- endfor -%},
          { "key": "sm_utm_source", "value": "facebook" },
          { "key": "sm_utm_medium", "value": "cpc" }
        ]
      }
    }
    ```
  </Step>
</Steps>

<Warning>
  The `orderUpdate` mutation **replaces all** `customAttributes`. The Liquid loop above preserves existing attributes—don't skip it or you'll lose data.
</Warning>

***

### Option 2: Admin API Script (Bulk)

For large backfills (hundreds or thousands of orders), use a script with the [Shopify Admin GraphQL API](https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderUpdate).

#### Prerequisites

<Check>Shopify Admin API access with `write_orders` scope</Check>
<Check>Order IDs mapped to your attribution data</Check>
<Check>Attribution data in the supported key format (see table above)</Check>

#### Implementation

<Tabs>
  <Tab title="GraphQL Mutation">
    ```graphql theme={null}
    mutation orderUpdate($input: OrderInput!) {
      orderUpdate(input: $input) {
        order {
          id
          customAttributes {
            key
            value
          }
        }
        userErrors {
          field
          message
        }
      }
    }
    ```

    **Variables:**

    ```json theme={null}
    {
      "input": {
        "id": "gid://shopify/Order/1234567890",
        "customAttributes": [
          { "key": "sm_utm_source", "value": "facebook" },
          { "key": "sm_utm_medium", "value": "cpc" },
          { "key": "sm_utm_campaign", "value": "summer_sale_2025" }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="Python Script">
    ```python theme={null}
    import shopify
    import csv

    # Initialize Shopify session
    shop_url = "your-store.myshopify.com"
    api_version = "2024-10"
    access_token = "your-access-token"

    session = shopify.Session(shop_url, api_version, access_token)
    shopify.ShopifyResource.activate_session(session)

    def backfill_order_attribution(order_id: str, attribution: dict):
        """
        Update an order with UTM attribution data.

        Args:
            order_id: Shopify order ID (numeric, e.g., "1234567890")
            attribution: Dict with keys like utm_source, utm_medium, etc.
        """
        # Build customAttributes array
        custom_attributes = [
            {"key": key, "value": value}
            for key, value in attribution.items()
            if value  # Skip empty values
        ]

        # GraphQL mutation
        query = """
        mutation orderUpdate($input: OrderInput!) {
          orderUpdate(input: $input) {
            order {
              id
              customAttributes { key value }
            }
            userErrors { field message }
          }
        }
        """

        variables = {
            "input": {
                "id": f"gid://shopify/Order/{order_id}",
                "customAttributes": custom_attributes
            }
        }

        result = shopify.GraphQL().execute(query, variables)
        return result

    # Example: Backfill from CSV
    with open('attribution_data.csv', 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            order_id = row['order_id']
            attribution = {
                'utm_source': row.get('source', ''),
                'utm_medium': row.get('medium', ''),
                'utm_campaign': row.get('campaign', ''),
            }

            result = backfill_order_attribution(order_id, attribution)
            print(f"Updated order {order_id}: {result}")
    ```
  </Tab>

  <Tab title="Node.js Script">
    ```javascript theme={null}
    import '@shopify/shopify-api/adapters/node';
    import { shopifyApi } from '@shopify/shopify-api';

    const shopify = shopifyApi({
      apiKey: process.env.SHOPIFY_API_KEY,
      apiSecretKey: process.env.SHOPIFY_API_SECRET,
      scopes: ['write_orders'],
      hostName: 'your-store.myshopify.com',
      apiVersion: '2024-10',
    });

    async function backfillOrderAttribution(session, orderId, attribution) {
      const client = new shopify.clients.Graphql({ session });

      const customAttributes = Object.entries(attribution)
        .filter(([_, value]) => value) // Skip empty values
        .map(([key, value]) => ({ key, value }));

      const response = await client.request(`
        mutation orderUpdate($input: OrderInput!) {
          orderUpdate(input: $input) {
            order {
              id
              customAttributes { key value }
            }
            userErrors { field message }
          }
        }
      `, {
        variables: {
          input: {
            id: `gid://shopify/Order/${orderId}`,
            customAttributes,
          },
        },
      });

      return response;
    }

    // Example usage
    const attribution = {
      utm_source: 'facebook',
      utm_medium: 'cpc',
      utm_campaign: 'summer_sale_2025',
    };

    await backfillOrderAttribution(session, '1234567890', attribution);
    ```
  </Tab>
</Tabs>

### Important Considerations

<AccordionGroup>
  <Accordion title="Merging vs Overwriting Attributes" icon="merge">
    The `orderUpdate` mutation **replaces** all `customAttributes` on the order. If the order already has attributes you want to keep:

    1. First, fetch existing attributes via `order` query
    2. Merge your new attributes with existing ones
    3. Send the combined array in the mutation

    ```python theme={null}
    # Fetch existing attributes first
    existing = get_order_attributes(order_id)
    merged = {**existing, **new_attribution}  # New values overwrite existing
    backfill_order_attribution(order_id, merged)
    ```
  </Accordion>

  <Accordion title="Rate Limits" icon="gauge">
    Shopify's Admin API has rate limits. For bulk backfills:

    * Use the [Bulk Operations API](https://shopify.dev/docs/api/admin-graphql/latest/queries/bulkOperationRunQuery) for large datasets
    * Or throttle requests to \~2 per second for standard API calls
    * Consider batching updates overnight
  </Accordion>

  <Accordion title="Order Age Limits" icon="calendar">
    Shopify allows updating orders regardless of age, but consider:

    * Very old orders may already have SourceMedium attribution from other sources
    * Order-level `customAttributes` are treated as an explicit override (see [Attribution Source Hierarchy](/data-transformations/attribution-source-hierarchy))
  </Accordion>
</AccordionGroup>

### Verification

After backfilling:

1. **Verify in Shopify Admin**: Orders → \[Order] → Additional details should show your attributes
2. **Wait for sync**: SourceMedium syncs typically run every 24 hours
3. **Check Orders Deep Dive**: Verify the attribution appears in SourceMedium dashboards

***

## Capturing UTMs for Future Orders

To automatically capture UTM parameters at checkout for new orders, use a Shopify Checkout UI Extension.

<Note>
  This approach **supplements** your existing tracking (GA4, Elevar). It's particularly useful when cookie-based tracking fails due to ad blockers or cross-domain issues.
</Note>

### How It Works

```mermaid theme={null}
flowchart LR
    A[User lands on site<br/>with UTM params] --> B[Storefront writes attribution<br/>to cart/checkout attributes]
    B --> C[Attributes become order<br/>customAttributes]
    C --> D[SourceMedium extracts<br/>from order data]
    D --> E[Attribution appears<br/>in dashboards]
```

### Prerequisites

<Check>Shopify Plus or ability to create Checkout UI Extensions</Check>
<Check>Access to deploy changes to your Shopify theme/app</Check>
<Check>Method to write cart attributes before checkout (theme JS / Storefront API)</Check>

### Step 1: Write Attribution to Cart Attributes (Before Checkout)

Checkout UI Extensions are sandboxed and can't directly access the browser DOM (e.g., `document.cookie`, `localStorage`, or `window.location`). To make UTMs available at checkout, capture them on the storefront and write them into **cart attributes** before the buyer starts checkout.

This example uses the Online Store `cart/update` endpoint to set SourceMedium override keys (`sm_utm_*`). These cart attributes flow into checkout attributes and appear on the order as `customAttributes`.

```javascript theme={null}
// theme.js (storefront)
function getParam(name) {
  return new URLSearchParams(window.location.search).get(name);
}

const attributes = {
  sm_utm_source: getParam('utm_source'),
  sm_utm_medium: getParam('utm_medium'),
  sm_utm_campaign: getParam('utm_campaign'),
  sm_utm_content: getParam('utm_content'),
  sm_utm_term: getParam('utm_term'),
  sm_utm_id: getParam('utm_id'),
  // Optional click IDs for fallback inference (not stored as raw values in SourceMedium)
  scclid: getParam('scclid'),
  irclickid: getParam('irclickid'),
  msclkid: getParam('msclkid'),
  ttclid: getParam('ttclid'),
  fbclid: getParam('fbclid'),
  gclid: getParam('gclid'),
  sm_referrer: document.referrer || null,
};

const cleanAttributes = Object.fromEntries(
  Object.entries(attributes).filter(([_, v]) => v != null && v !== '')
);

if (Object.keys(cleanAttributes).length > 0) {
  fetch('/cart/update.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ attributes: cleanAttributes }),
  });
}
```

<Warning>
  Set these attributes **before** the buyer starts checkout. If you update cart attributes after checkout is already open, they might not be reflected unless the buyer refreshes checkout.
</Warning>

<Note>
  If you're using a headless storefront, use the Storefront API to set cart attributes instead of `cart/update.js`.
</Note>

### Step 2: Create Checkout UI Extension

```typescript theme={null}
// extensions/utm-capture/src/Checkout.tsx
import { useEffect } from 'react';
import {
  reactExtension,
  useApplyAttributeChange,
  useAttributes,
} from '@shopify/ui-extensions-react/checkout';

export default reactExtension('purchase.checkout.block.render', () => <UtmCapture />);

function UtmCapture() {
  const applyAttributeChange = useApplyAttributeChange();
  const currentAttributes = useAttributes();

  useEffect(() => {
    void ensureSmOverrideKeys();
  }, []);

  function getAttributeValue(key: string): string | null {
    return currentAttributes.find(attr => attr.key === key)?.value ?? null;
  }

  async function ensureSmOverrideKeys() {
    // Checkout UI extensions cannot access cookies or localStorage.
    // This helper maps any existing `utm_*` attributes to `sm_utm_*` override keys.
    const mapping: Record<string, string | null> = {
      sm_utm_source: getAttributeValue('sm_utm_source') ?? getAttributeValue('utm_source'),
      sm_utm_medium: getAttributeValue('sm_utm_medium') ?? getAttributeValue('utm_medium'),
      sm_utm_campaign: getAttributeValue('sm_utm_campaign') ?? getAttributeValue('utm_campaign'),
      sm_utm_content: getAttributeValue('sm_utm_content') ?? getAttributeValue('utm_content'),
      sm_utm_term: getAttributeValue('sm_utm_term') ?? getAttributeValue('utm_term'),
      sm_utm_id: getAttributeValue('sm_utm_id') ?? getAttributeValue('utm_id'),
    };

    for (const [key, value] of Object.entries(mapping)) {
      if (!value) continue;

      const existing = currentAttributes.find(attr => attr.key === key);
      if (existing) continue;

      try {
        await applyAttributeChange({ type: 'updateAttribute', key, value });
      } catch (err) {
        // Silently fail - accelerated checkout will throw
        console.debug(`[UTM Capture] Error setting ${key}:`, err);
      }
    }
  }

  return null;
}
```

### Step 3: Configure and Deploy

```toml theme={null}
# extensions/utm-capture/shopify.extension.toml
api_version = "2024-10"

[[extensions]]
type = "ui_extension"
name = "UTM Attribution Capture"
handle = "utm-capture"

[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.block.render"
```

```bash theme={null}
shopify app deploy
```

### Limitations

<Warning>
  **Accelerated Checkout**: The `applyAttributeChange()` method fails for Apple Pay, Google Pay, and Shop Pay express checkouts. This typically affects 10-30% of orders. Use this as a supplement to GA4/Elevar, not a replacement.
</Warning>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Attributes appear in Shopify but not SourceMedium">
    1. **Unsupported key**: Only allowlisted keys are extracted (see "Supported Keys")
    2. **Sync timing**: Wait 24-48 hours for data to flow through
    3. **Connector version**: Ensure your Shopify connector supports `customAttributes` (contact support to verify)
  </Accordion>

  <Accordion title="Backfill script failing with permission errors">
    Ensure your API credentials have `write_orders` scope. For private apps, this must be enabled in the app settings.
  </Accordion>

  <Accordion title="Existing attributes being overwritten">
    The `orderUpdate` mutation replaces all attributes. Fetch existing attributes first, merge, then update.
  </Accordion>
</AccordionGroup>

***

## Related Resources

<CardGroup cols={2}>
  <Card title="Shopify Admin API - Order Update" icon="shopify" href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderUpdate">
    Official documentation for the orderUpdate mutation
  </Card>

  <Card title="Shopify Checkout Attributes API" icon="code" href="https://shopify.dev/docs/api/checkout-ui-extensions/latest/apis/attributes">
    Documentation for useApplyAttributeChange hook
  </Card>

  <Card title="UTM Setup" icon="bullseye" href="/help-center/core-concepts/attribution/utm-setup">
    What UTMs are, what each value means, and how to tag links
  </Card>

  <Card title="Attribution Source Hierarchy" icon="sitemap" href="/data-transformations/attribution-source-hierarchy">
    How SourceMedium prioritizes attribution from multiple sources
  </Card>
</CardGroup>

***

## FAQ

<AccordionGroup>
  <Accordion title="Which method should I use?">
    * **Backfill**: You have historical attribution data you want to add to existing orders
    * **Capture going forward**: You want to automatically capture UTMs for new orders
    * **Both**: Most brands benefit from backfilling historical data AND capturing future UTMs
  </Accordion>

  <Accordion title="What's the priority if multiple sources have UTMs?">
    SourceMedium uses an [attribution source hierarchy](/data-transformations/attribution-source-hierarchy). If allowlisted attribution is present in order `customAttributes`, it is treated as an explicit override and takes top priority.
  </Accordion>

  <Accordion title="Can I backfill orders that already have attribution in SourceMedium?">
    Yes. If you write allowlisted attribution to order `customAttributes`, it is treated as an explicit override and will replace the order's last-touch attribution (unless you only set a subset of fields, in which case missing fields may be filled from lower-priority sources when the channel matches).
  </Accordion>

  <Accordion title="How do I know if my connector supports this?">
    SourceMedium's latest Shopify connector extracts `customAttributes` automatically. Contact support if you're unsure whether your connector supports this feature.
  </Accordion>
</AccordionGroup>
