Skip to main content

Query campaign sales

In this tutorial, you'll extend the QR code app into a campaign dashboard using Shopify Analytics. The dashboard reports a marketing campaign's results and charts the store's analytics. You'll register each QR code as a marketing activity and track visits, conversion rate, and sales for each scan.

Use the AI toolkit to build and customize the app. The sample repository contains the finished app from all three tutorials, and some files that you copy in this part contain code for parts two and three.

You'll learn how to:

Requirements

Build the QR code app

You've completed Build a Shopify app using React Router. If your app uses TypeScript, then add types to the JavaScript files that you copy.

Update Shopify CLI

You're using the latest version of Shopify CLI.

Request protected customer data access

You have Level 2 access to protected customer data in the Partner Dashboard.

Project

Anchor to Update your access scopes and API versionUpdate your access scopes and API version

You'll start by adding the required access scopes. Your app reads the store's sales with ShopifyQL, stores campaigns as metaobject entries, and creates metafield definitions on products, customers, and orders. In part two, your app writes values to the metafields.

Add the following access scopes, set the API version, and turn on direct API access.

Anchor to Add the access scopesAdd the access scopes

Add these eight scopes to the [access_scopes] section of shopify.app.toml:

ScopeAuthorizes
read_reportsshopifyqlQuery.
read_analytics_annotationsThe store's analytics annotations.
write_products, write_customers, write_ordersmetafieldDefinitionCreate and metafieldsSet on products, customers, and orders.
write_metaobject_definitions, write_metaobjectsThe Creator and Campaign metaobject definitions, their entries, and the QR code entries.
write_marketing_eventsmarketingActivityUpsertExternal for each QR code.

Note

shopify app dev syncs shopify.app.toml to a preview on a development store. For a published app, Shopify syncs the configuration from a released app version.

Move the QR code app from the January 2026 API version to October 2026. Upgrade the Shopify React Router app package to version 3 and the Prisma session storage package to version 11. Then change both API version settings in app/shopify.server.js to October 2026. In the same file, delete the restResources import and option. Version 3 of the package doesn't accept REST resources.

Replace .graphqlrc.js with the version from the sample repository. The original file imports the package root, and version 3 doesn't have a root entry point.

In shopify.app.toml, set the webhooks API version to 2026-10.


Anchor to Turn on direct API accessTurn on direct API access

Your app's metric cards query the GraphQL Admin API from the browser. Turn on direct API access in shopify.app.toml:

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

Anchor to Install the app and confirm the scopesInstall the app and confirm the scopes

Use Shopify CLI to run the app on your development store:

Terminal

shopify app dev

Press p to open the preview URL, and then click Install app. The install prompt lists the scopes that the app requests.


Troubleshooting
Anchor to The prompt lists fewer scopes than you setThe prompt lists fewer scopes than you set

Stop shopify app dev, confirm that all eight scopes are on one comma-separated line under [access_scopes], and then run the command again.

If you cloned the sample repository, then shopify app config link overwrites the scopes line. Add the scopes again after you link the app.

Anchor to Create and store a campaignCreate and store a campaign

Your app stores each campaign as a metaobject entry. Every sales query on the campaign's page uses the campaign's start and end dates as the query's date range.

Anchor to Add the campaign filesAdd the campaign files

Copy these files into your app directory:

  • app/routes/app.campaigns._index.jsx: The campaign list and the New campaign form.
  • app/routes/app.campaigns.$campaignId.jsx: The campaign page with the charts and reports.
  • app/campaigns.server.js: The Creator and Campaign definitions and entries.
  • app/campaign-queries.server.js: The GraphQL queries and mutations.
  • app/dimensions.server.js: Your app's metafield definitions and their analytics settings.
  • app/campaigns.js: The date range for each query, the filters, and the dimension names.
  • app/annotations.js: The ANNOTATE clause and the link that opens a query in the Shopify admin.
  • app/audiences.js: The customer segments and the metafield that stores the segment.
  • app/channels.js: The email, SMS, and push channels.
  • app/panels/sales.jsx: The store totals, metric card, daily sales chart, and top products table.
  • app/panels/dimensions.jsx: The promoted product, segment, order attribution, creator revenue, and QR code panels.
  • app/panels/shared.jsx: The panel heading, the percentage text for a panel's share of store sales, and the chips that show each panel's Shopify features, such as ShopifyQL.
  • app/campaign-state.server.js: The app's database records for each campaign.
  • app/shopifyql.server.js: The ShopifyQL request, with a retry for throttled queries.

Anchor to Define the campaign and creator metaobjectsDefine the campaign and creator metaobjects

Your app creates two merchant-owned metaobject definitions. In API version 2026-10, ShopifyQL groups sales only by a merchant-owned type.

Each campaign is a campaign_dashboard_campaign entry. Each creator is a campaign_dashboard_creator entry with a name and a list of discount codes. A campaign's creators field is a list of references to Creator entries.


Anchor to Create the definitionsCreate the definitions

Your app creates both definitions at runtime with metaobjectDefinitionCreate. Definitions in shopify.app.toml are app-owned. In API version 2026-10, ShopifyQL can't group sales by an app-owned definition.

Start with the Creator definition. The Campaign creators field validates against the Creator definition's ID. A merchant-owned definition's ID doesn't change when a user reinstalls your app. Shopify assigns an app-owned definition a new ID, which breaks the validation.

Note

You can't change the validation after you create the field.


Anchor to Store the app's records in its databaseStore the app's records in its database

App users can edit campaign entries in the Shopify admin. Keep the records that your app reads in your app's database:

  • ShopState: The highest campaign ID that your app has assigned in the store.
  • CampaignState: The annotation and target IDs that your app created for each campaign, and the number of App Events that your app sent for the campaign.
  • CampaignLock: One row for each campaign write in progress.

Add the three models to prisma/schema.prisma, and copy the prisma/migrations/20261007120000_add_campaign_state and prisma/migrations/20261007130000_add_stamp_operation folders from the sample repository.

Anchor to Apply the migrationsApply the migrations

Stop shopify app dev, and then run the command again. The dev command in shopify.web.toml applies the migrations when the app starts:

Terminal

shopify app dev

Anchor to Read and write the campaign entriesRead and write the campaign entries

A throttled request makes admin.graphql throw, and the action stops without undoing the earlier writes. Wrap admin with withReturnedErrors in each loader and action of the campaign routes to return the errors instead:

JavaScript

const {admin: client, session} = await authenticate.admin(request);
const admin = withReturnedErrors(client);

Pass session.shop to readCampaigns, which merges each entry with the entry's CampaignState row.

Warning

A failed read returns an empty campaigns list and puts the errors in failed. The empty list looks the same as the list for a store with no campaigns. Check failed before you add the sample campaigns or assign a new campaign ID.


Anchor to Define the customer segments and channelsDefine the customer segments and channels

A campaign targets one customer segment. Your app writes the segment onto each targeted customer as a metafield. In part two, you make the metafield queryable in ShopifyQL.

app/channels.js lists the email, SMS, and push channels.

Anchor to Build the creation formBuild the creation form

Add a multi-select channel picker to the New campaign form with s-choice-list and three s-choice elements, one for each channel.


Anchor to Validate the form and write the entryValidate the form and write the entry

Write the creators with metaobjectUpsert. Write the planned campaign entry with metaobjectCreate, and then redirect to the campaign page.

The action also validates the four target amounts. The form in this part doesn't have target fields. A blank amount means no target.

Anchor to Pick the promoted productsPick the promoted products

Call shopify.resourcePicker to open the Shopify admin's product search. Limit the selection to five products, and pass the current selection as selectionIds.


Anchor to Move the QR code list to ,[object Object]Move the QR code list to /app/qrcodes

Move the QR code list from app/routes/app._index.jsx to app/routes/app.qrcodes._index.jsx, and add a Campaign column. Each Campaign cell links to the QR code's campaign.

Anchor to Point the app at the campaign listPoint the app at the campaign list

Replace app/routes/app._index.jsx with a loader that redirects /app to the campaign list. Then link the campaign and QR code lists in the app's navigation, and delete app/routes/app.additional.jsx if it exists.

Anchor to Add two sample campaignsAdd two sample campaigns

Add two planned sample campaigns. The Summer launch campaign's dates cover today. The campaign lists two creators. Avery owns the AVERY10 and AVERYSUMMER codes, and Juno owns JUNO15.

Anchor to Subscribe to the compliance webhooksSubscribe to the compliance webhooks

Apps on the Shopify App Store must use three compliance webhooks.

In shopify.app.toml, subscribe to three topics under compliance_topics:

  • customers/data_request.
  • customers/redact.
  • shop/redact.

Then add a route for each topic:

  • app/routes/webhooks.customers.data_request.jsx logs the customer's segment. A production app sends the customer's segment to the store owner.
  • app/routes/webhooks.customers.redact.jsx deletes the customer's segment metafield.
  • app/routes/webhooks.shop.redact.jsx deletes the store's sessions and campaign records. Shopify sends shop/redact 48 hours after a store owner uninstalls your app.

Anchor to Query the store's salesQuery the store's sales

The campaign page shows app users the store's sales during the campaign. Each query reads the sales schema for the campaign's dates.

Send each statement to the shopifyqlQuery query through the app's queryShopifyQL helper. Run the statements in parallel.

The helper retries a throttled query once when the store's ShopifyQL limit refills within 10 seconds. Otherwise, the helper returns an error with the time that the limit refills.

Wrap the optional panel queries in runOptional. A failed optional query returns an empty table. The page still renders the other panels.

When a campaign started before today and ends after today, set UNTIL to today.


A failed query and a store with $0 in sales both show zero. Check errors and parseErrors before you read tableData.


Anchor to Format each data typeFormat each data type

ShopifyQL returns money as decimal strings such as "16161.6". Pass each string to Intl.NumberFormat with the store's currency, and parse it to a number only when you calculate with it. Append T00:00:00 to each YYYY-MM-DD date, or browsers west of UTC show the previous day.


Anchor to Chart the daily rows with a metric cardChart the daily rows with a metric card

Pass salesCardQuery to s-shopifyql-metric-card, which runs the query and draws daily sales as bars and orders as a line.

Add the analytics script to the app layout. Render the card after window.shopify.shopifyQL loads, or the card stays in an error state.

The card fills its container's height. Wrap the card in an s-box with a fixed blockSize.

List each day's orders and sales below the chart with s-table.


Anchor to Rank the top productsRank the top products

GROUP BY TOP 5 returns the five products with the most sales in the campaign's window and folds every other product into one remainder row:

ShopifyQL

FROM sales
SHOW total_sales
GROUP BY TOP 5 product_title_at_time_of_sale
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY total_sales DESC

Find the remainder row with topNRemainderColumnNames in the rowMetadata of tableData, and render the remainder row last as Other products.


Anchor to Group several discount codes under one content creatorGroup several discount codes under one content creator

Orders record the discount code, and Creator entries map each code to a creator. Group the campaign's sales by discount_code, and then add up the rows for each creator's codes:

ShopifyQL

FROM sales
SHOW discount_code, total_sales, orders
WHERE discount_code IN ('AVERY10', 'AVERYSUMMER', 'JUNO15')
GROUP BY discount_code
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY total_sales DESC

  • Sanitize the codes before they reach the query. Each code sits inside a quoted ShopifyQL literal, and an apostrophe closes the literal early.

Anchor to Connect the QR codes to Shopify attributionConnect the QR codes to Shopify attribution

Shopify credits an order to a marketing activity when the visit's landing URL contains the activity's UTM parameters. Register an activity for each QR code, and add the activity's UTM parameters to the scan's destination.

Anchor to Update the QR code modelUpdate the QR code model

Replace app/models/QRCode.server.js with the version on the sample repository's part-1 branch. In your app configuration file, add a campaign text field and a live_on date field to the QR code definition. Shopify CLI syncs the change while the dev server runs.

The campaign field stores the campaign entry's GID as text. A reference field in the app configuration file can point only to an app-owned or standard definition.

The model registers each QR code as a marketing activity with marketingActivityUpsertExternal. The code's handle is the activity's remoteId and utm_campaign value.

Note

Leave out hierarchyLevel. You can't change hierarchyLevel after you set a value.


Anchor to Register and end the activity from the form actionRegister and end the activity from the form action

Replace app/routes/app.qrcodes.$id.jsx with the version on the sample repository's part-1 branch. Saving a QR code creates or updates the code's marketing activity. Deleting a QR code ends the activity.

Your app deletes a QR code only when the code doesn't belong to a campaign or belongs to a planned campaign. The campaign page for a running or closed campaign still reports the code's scans and sales.

Troubleshooting
Anchor to Form saves return a 400 through a dev tunnelForm saves return a 400 through a dev tunnel

React Router returns 400 Bad Request for an action POST whose Origin host differs from the request URL's host. A dev tunnel can make the hosts differ. Create react-router.config.js in the project root and allow the tunnel and admin hosts:

export default {
allowedActionOrigins: ["*.trycloudflare.com", "admin.shopify.com"],
};

Anchor to Add the UTM parameters to each scanAdd the UTM parameters to each scan

Replace app/routes/qrcodes.$id.scan.jsx with the version on the sample repository's part-1 branch. Your app now redirects each scan to a URL with the code's UTM parameters. Shopify reads the parameters to attribute the visit.

Anchor to Track a QR code's sessions in the Shopify adminTrack a QR code's sessions in the Shopify admin

Assign each QR code's activity to a marketing campaign in the Shopify admin. The marketing campaign's report then shows the sessions that follow a scan.

Anchor to Assign the activity to a marketing campaignAssign the activity to a marketing campaign

  1. Save a QR code in the app, and then scan the code or open the code's destination URL.
  2. From the Shopify admin, open Growth > Campaigns.
  3. Create a marketing campaign, and then assign the QR code's activity to the marketing campaign.

Anchor to Open the campaign report after a scanOpen the campaign report after a scan

  1. Scan the QR code.
  2. From the Shopify admin, open Growth > Campaigns, and then open the marketing campaign that you assigned the activity to.

The campaign report shows the QR code's sessions.

Troubleshooting
Anchor to The Sessions metric shows 0 after a scanThe Sessions metric shows 0 after a scan

Shopify Analytics fills in the channel and UTM breakdowns after a delay. Reload the report later.

Anchor to Report the sales from QR code scansReport the sales from QR code scans

The QR code sales panel shows app users the sales from each QR code. Each query filters on the codes' UTM parameters.

Anchor to Assign each QR code to a campaignAssign each QR code to a campaign

The QR code form's Campaign select lists your app's planned campaigns, which are separate from the marketing campaigns under Growth > Campaigns.

An app user can move a code only between planned campaigns. heldByTargets also blocks a move when a failed activation left targets on a planned campaign.

You build activation in part two. Every move in this part passes the check.

Anchor to Query each QR code's salesQuery each QR code's sales

The campaign_sales schema reports the orders that Shopify attributes to marketing. Filter on utm_medium and the handles of the campaign's QR codes, and group by utm_campaign for one row for each code:

ShopifyQL

FROM campaign_sales
SHOW utm_campaign, campaign_last_non_direct_click_total_sales,
campaign_last_non_direct_click_order_count
WHERE utm_medium = 'qr'
AND utm_campaign IN ('counter-card', 'window-poster')
GROUP BY utm_campaign
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY campaign_last_non_direct_click_total_sales DESC

  • Allow only lowercase letters, numbers, hyphens, and underscores in each handle. The handle sits inside a quoted ShopifyQL literal.
  • The metrics follow the last non-direct click attribution model.

Anchor to Query each QR code's sessionsQuery each QR code's sessions

The campaign_sessions schema counts the visits that each scan started. Query each code's sessions, add-to-cart rate, and conversion rate:

ShopifyQL

FROM campaign_sessions
SHOW campaign_sessions, campaign_added_to_cart_rate, campaign_conversion_rate
WHERE utm_campaign IN ('counter-card', 'window-poster')
GROUP BY utm_campaign
SINCE 2026-07-22 UNTIL 2026-08-04

Multiply each rate by 100 to show the rate as a percentage.


Anchor to Chart the scans and list the codesChart the scans and list the codes

Merge the sales rows and the sessions rows by utm_campaign into one row for each code.


Anchor to Run the app and open a campaignRun the app and open a campaign

If shopify app dev isn't running, then start the app on your development store:

Terminal

shopify app dev

Open the Summer launch sample campaign. The sales panel compares the store's totals with the prior period and last year, and charts each day's sales. Top products lists the best sellers.

Creator revenue lists Avery's and Juno's codes. The sample data doesn't create discount codes in Shopify for Avery and Juno. Each code's sales stay at $0 until an order uses the code.

QR code sales appears after you assign a QR code to the campaign and a scan of the QR code starts a session.

The promoted product, segment, and order attribution panels stay hidden until you finish part two.


  • Test orders don't reach the sales schema. On a development store, create orders with the orderCreate mutation and a processedAt date inside the campaign's dates.

Troubleshooting
Anchor to QR code sales stay at $0 on a development storeQR code sales stay at $0 on a development store

A development store accepts only test payments, and test orders don't reach the sales and campaign_sales schemas. The QR code sales panel lists each scanned code's sessions, and each code's sales stay at $0.

Anchor to The sales panel returns an access errorThe sales panel returns an access error

Your app is missing the read_reports scope or Level 2 protected customer data access. Add read_reports to shopify.app.toml or request Level 2 access from the Partner Dashboard, and then run the app again.

Was this page helpful?