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.
Anchor to What you'll learnWhat you'll learn
You'll learn how to:
- Store campaigns and content creators as metaobjects.
- Assign QR codes to a campaign.
- Query the store's sales with ShopifyQL and compare them to a previous period.
- Chart sales in your app with analytics web components.
- Track each QR code as a marketing activity and report the sales and visits from its scans.
Requirements
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.
You're using the latest version of Shopify CLI.
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:
| Scope | Authorizes |
|---|---|
read_reports | shopifyqlQuery. |
read_analytics_annotations | The store's analytics annotations. |
write_products, write_customers, write_orders | metafieldDefinitionCreate and metafieldsSet on products, customers, and orders. |
write_metaobject_definitions, write_metaobjects | The Creator and Campaign metaobject definitions, their entries, and the QR code entries. |
write_marketing_events | marketingActivityUpsertExternal for each QR code. |
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.
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.
Anchor to Set the API versionSet the API 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:
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
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: TheANNOTATEclause 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.
You can't change the validation after you create the field.
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
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
Pass session.shop to readCampaigns, which merges each entry with the entry's CampaignState row.
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.
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
/app/qrcodesMove 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.jsxlogs the customer's segment. A production app sends the customer's segment to the store owner.app/routes/webhooks.customers.redact.jsxdeletes the customer's segment metafield.app/routes/webhooks.shop.redact.jsxdeletes the store's sessions and campaign records. Shopify sendsshop/redact48 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.
Anchor to Run the queryRun the query
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.
Anchor to Check the responseCheck the response
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
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
- 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.
Leave out hierarchyLevel. You can't change hierarchyLevel after you set a value.
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:
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
- Save a QR code in the app, and then scan the code or open the code's destination URL.
- From the Shopify admin, open Growth > Campaigns.
- 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
- Scan the QR code.
- 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
- 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
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
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
salesschema. On a development store, create orders with theorderCreatemutation and aprocessedAtdate 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.
Anchor to Part one complete!Part one complete!
You built a campaign dashboard on Shopify Analytics. Each campaign page compares sales with the prior period and last year. The page also reports new customers, top products, creator code sales, and the sessions and sales from QR code scans.
In part two, you'll set revenue targets, mark the campaign on the store's charts, and group sales by promoted product and customer segment.