Skip to main content

Add targets and annotations

In this tutorial, you'll add revenue targets and annotations to the campaign dashboard. When an app user activates a campaign, your app creates the targets that the user set and marks the campaign's dates on the store's charts.

You'll learn how to:

  • Set optional revenue targets for the campaign and each QR code.
  • Mark the campaign's key dates on the store's sales charts with annotations.
  • Show the campaign's sales and key metrics in your app with analytics web components.
  • Break the campaign's revenue down by promoted product and by customer segment.
  • Close the campaign, record its revenue, and stamp its orders with a bulk operation.

Requirements

Complete part one

You've built the campaign dashboard from part one.

Update Shopify CLI

You're using the latest version of Shopify CLI.

Project

Anchor to Set campaign revenue targetsSet campaign revenue targets

A target is a revenue goal that Shopify measures against the store's sales. Add the access scopes, and then create targets for the store, the promoted products, the customer segment, and each QR code.

Anchor to Add the access scopesAdd the access scopes

To create targets, your app must have the write_reports scope. To create annotations, your app must have the write_analytics_annotations scope. In shopify.app.toml, replace the scopes line under [access_scopes]:

shopify.app.toml

scopes = "write_reports,write_analytics_annotations,write_products,write_customers,write_orders,write_metaobject_definitions,write_metaobjects,write_marketing_events"

While shopify app dev runs, Shopify CLI syncs the new scopes. Open the app in the Shopify admin and accept the updated scopes.


Anchor to Add the part two filesAdd the part two files

Copy these files from the sample repository's part-2 branch into your app directory:

  • app/campaign-targets.server.js measures each target's progress.
  • app/campaign-mutations.server.js defines the mutations that your app runs.
  • app/campaign-report.server.js builds the result that your app writes to the store's timeline when a user closes a campaign.
  • app/bulk-stamp.server.js writes the order metafields in a bulk operation and reads the operation's status.
  • app/panels/targets.jsx renders a card for each target.
  • app/panels/close.jsx renders the Campaign actions and Order attribution sections.
  • app/panels/confirm.jsx exports ConfirmButton, which opens a confirmation dialog before the form submits a destructive or one-way action.
  • app/routes/app.campaigns.$campaignId.stamping.jsx returns the status of the bulk operation that stamps the campaign's orders.

Replace app/routes/app.campaigns.$campaignId.jsx and app/routes/app.campaigns._index.jsx with the part two versions. The part two campaign list has target fields on the creation form and two new columns: Target progress and Annotations and targets.

Anchor to Collect the target amounts on the creation formCollect the target amounts on the creation form

The New campaign form has a Targets section after Channels. The section has one s-money-field for each target:

  • Store sales target
  • Promoted product target
  • Customer segment target, for the VIP and Regulars segments
  • QR code target

Your app skips the target for a blank field. Before activation, the user can change the amounts under Targets on the campaign page.

isTargetAmount checks each amount on the form and on the campaign page. It accepts only a positive amount with at most two decimals.

Anchor to Create the store-wide targetCreate the store-wide target

Use analyticsTargetCreate with the total_sales metric, the target amount, and the campaign's dates. A target without filters measures the whole store. Save the returned ID in the campaign's CampaignState row.


Anchor to Narrow a target to part of the storeNarrow a target to part of the store

Add a filters string to limit a target to the promoted products or the customer segment:

"filters": "product_id IN (7263, 7264)"
"filters": "customer.metafields.\"app--{app_id}--campaign_dashboard\".tier = 'vip'"

product_id takes the number at the end of a product GID. Wrap the metafield namespace in double quotes because the namespace contains --.

Anchor to Read the query that a target generatesRead the query that a target generates

Read the campaign's targets with the nodes query on the saved IDs. Your app saves a target and an annotation for each QR code. The nodes query accepts at most 250 IDs. readNodes sends the IDs in batches of 250 or fewer. Run each target's generated query in one shopifyqlQuery request under aliases such as t0 and t1.

COMPARE TO TARGETS doesn't return target values through shopifyqlQuery. Read the target values from the target nodes.


Anchor to Set a goal for each QR codeSet a goal for each QR code

Give each QR code a separate target that filters on the QR code's handle. Call analyticsTargetCreate once for each QR code in the campaign:

  • metric: campaign_last_non_direct_click_total_sales.
  • filters: utm_campaign = '<handle>'.
  • expectedValue: The campaign's QR code target.
  • startDate and endDate: The QR code's Live on day through the campaign's last day.

Save each target ID in the campaign's CampaignState row under qr:<handle>.


Anchor to Render a target as a share of the target amountRender a target as a share of the target amount

Show each target's progress as a percentage of the target amount, and display the running total with an s-shopifyql-metric-card.


Anchor to Raise the store-wide target after the campaign passes itRaise the store-wide target after the campaign passes it

When a running campaign passes the store-wide target, the user can click Raise target to set a higher goal. Your app sets the new target to the next round amount above the campaign's sales, and then sends that amount to analyticsTargetUpdate as expectedValue.


Anchor to Annotate the campaign and activate itAnnotate the campaign and activate it

An annotation is a dated note on the store's sales charts. When a user clicks Activate campaign, your app writes the annotations, targets, and metafields in one action.

Anchor to Write an annotation for each campaign markWrite an annotation for each campaign mark

Use analyticsAnnotationCreate to write one annotation for each dated event in the campaign, such as the launch or a channel's send date. Take each type from the annotation categories and types reference.

The type sets the annotation's category. ANNOTATE draws annotations by category or by category.type.

Only the whole-campaign annotation has a duration. Pass null as the annotation's endedAt to keep the annotation open while the campaign runs.

Click Activate campaign on a planned campaign, and then open the store's sales report in the Shopify admin. The annotations appear on the date axis.


Anchor to Mark the day each QR code goes liveMark the day each QR code goes live

Add an annotation on each QR code's Live on day in campaignMarks. When a user clicks Rewrite annotations, your app reruns writeMarks. writeMarks deletes the campaign's planned annotations, and then writes the campaign's current marks. writeMarks doesn't delete milestone or incident annotations.


Anchor to Update an annotation in placeUpdate an annotation in place

Use analyticsAnnotationUpdate to rewrite an annotation's title and set endedAt in one call, without deleting and recreating the annotation. The mutation changes only the fields that you pass.


Anchor to Query the annotationsQuery the annotations

Query analyticsAnnotations on shop for the campaign's dates. The results include annotations from every app. Check createdByApp before you edit an annotation.


Troubleshooting
Anchor to [object Object], rejects a type that you found in the referenceanalyticsAnnotationCreate rejects a type that you found in the reference

Only the ANNOTATE clause takes the dotted category.type form. Pass the mutation the bare type name, such as campaign.

The mutation also rejects a type that's read-only for apps. Check the operations column in the annotation categories and types reference.

Anchor to A mutation returns ,[object Object], for an ID that you just readA mutation returns NOT_FOUND for an ID that you just read

The connection returns annotations from every installed app. The update and delete mutations reach only your app's annotations. For another app's annotation, the mutations return NOT_FOUND. Check createdByApp before you show an edit or delete control.

Anchor to Handle activation and cancellation in one actionHandle activation and cancellation in one action

Handle Activate campaign and Cancel campaign in the campaign page's route action. On activation, your app writes the annotations, targets, and metafields, and then sets the status to running.

On cancellation, your app reads the saved target and annotation IDs. Then your app deletes the annotations with analyticsAnnotationDelete and the targets with analyticsTargetsDelete. deleteTargets sends the target IDs in batches of 250 or fewer.


  • Save the target IDs in the CampaignState row before you change the status. The saved IDs limit a cancellation to this campaign's targets.
  • When a user activates the campaign again after a failure, reuse the saved IDs and create only the missing targets.
  • Block edits to the products, segment, and amounts while the CampaignState row stores target IDs. To change the products, segment, or amounts, the user cancels and replans the campaign.
  • If Shopify rejects a target, then your app finishes the activation and lists the target under Completed with warnings.

Anchor to Lock the campaign during a writeLock the campaign during a write

Two requests can read the same planned campaign and both activate it. lockCampaign locks the campaign with a CampaignLock row in your app's database until the first request finishes. Take the lock before each form submission, and release the lock in a finally block.

Anchor to Chart the campaign with annotations and web componentsChart the campaign with annotations and web components

Annotations mark each campaign event on the sales chart, beside the day's sales.

Anchor to Check the annotations on the chartsCheck the annotations on the charts

The sales card query from part one already includes COMPARE TO TARGETS and ANNOTATE ALL. The QR code sales chart's query ends with an ANNOTATE clause.

Activate a campaign with a QR code, and then open the campaign. The sales chart shows the annotations, and the QR code chart shows each code's annotation on the Live on day.


Anchor to Render the key metrics with web componentsRender the key metrics with web components

Part one's app/panels/sales.jsx renders an s-metrics-bar above the chart. Inside the bar, the panel renders an s-metrics-bar-date-picker and one s-shopifyql-metric-card for each key metric.

When a user picks a range, the date picker returns the range as an alias, such as last7days. The panel maps each alias to a SINCE expression, and then rebuilds every card's query.


Part one's app/annotations.js builds a shopify://admin/analytics/reports/explore link from the sales chart's query. The link's ANNOTATE clause lists the category.type of each annotation. The link opens the report under Analytics in the Shopify admin.


Anchor to Group the campaign's sales by product and customer segmentGroup the campaign's sales by product and customer segment

Your app writes campaign values onto products, customers, and orders as metafields. You can group a report's sales by the tier and campaign_touch metafields after your app turns on analytics for each metafield definition. You can group a report's sales by the product campaign metafield after a user turns on analytics for the Campaign metaobject definition.

Anchor to Define the three app-reserved dimensionsDefine the three app-reserved dimensions

Part one's app/dimensions.server.js defines one metafield definition for each resource:

  • A campaign definition on products.
  • A tier definition on customers.
  • A campaign_touch definition on orders.

The three metafield definitions use the app-reserved namespace $app:campaign_dashboard with admin access set to MERCHANT_READ. Your app creates each definition when a user clicks Create dimension under Analytics dimensions.

campaign is a metaobject_reference. You can group a report's sales by a field on the referenced campaign entry when the field's type supports analytics, such as the number_integer field campaign_id.

Note

After you create the campaign metafield definition, you can't change the metaobject definition that the metafield references.

Anchor to Make a metafield definition queryableMake a metafield definition queryable

Set capabilities.analyticsQueryable.enabled to true on the tier and campaign touch definitions. Create the product definition without the analytics queryable capability, because the metaobject reference type doesn't support the capability.

Check capabilities.analyticsQueryable.eligible to confirm that a definition supports analytics. Eligibility depends on the metafield type and the owner. Customers, orders, products, and product variants are the supported owners.


Anchor to Ask the user to turn on analyticsAsk the user to turn on analytics

In API version 2026-10, apps can't turn on analytics for a metaobject definition. The user turns on Filter or group data in Analytics for the Campaign definition in the Shopify admin. The Turn on analytics link under Analytics dimensions opens the Campaign definition.

The setting takes effect about 30 seconds after the user saves. Until then, ShopifyQL returns Metafield Column Not Found for the product column.

Anchor to Stamp the promoted products and group the salesStamp the promoted products and group the sales

On activation, write the campaign entry's GID to the campaign metafield on each promoted product. Send up to 25 products in each metafieldsSet call.

Each product references one campaign. If a later campaign promotes the same product, then your app replaces the reference.

Promoted product sales groups daily sales by the entry's campaign_id field:

ShopifyQL

FROM sales
SHOW product.metafields."app--{app_id}--campaign_dashboard".campaign.campaign_id AS promoted_by,
total_sales
GROUP BY promoted_by
TIMESERIES day
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY day ASC

ShopifyQL returns promoted_by as a string, such as "4210". Rows that match this campaign's ID are the promoted products. Every other row counts as the rest of the store.


Anchor to Rank the customer segment and filter the salesRank the customer segment and filter the sales

CustomerSortKeys has no spend key. To rank customers by spend, write a sales query that does the following:

  • Groups by customer_id.
  • Orders by total_sales DESC.
  • Limits the result to the segment's size.

The Regulars segment skips the 10 VIP customers with OFFSET 10.

Write the segment to the tier metafield on each ranked customer. A rerank can push customers out of the segment. Query the customers schema for the tier's customers, 1,000 at a time with OFFSET. Delete the tier metafield from customers outside the new ranking.

All campaigns use the same tier metafield. When a user activates another VIP or Regulars campaign, your app reranks customers. The new ranking can change a running campaign's segment sales.

Filter the campaign's sales on tier:

ShopifyQL

FROM sales
SHOW total_sales, orders
WHERE customer.metafields."app--{app_id}--campaign_dashboard".tier = 'vip'
TIMESERIES day
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY day ASC

A user can change the segment while the campaign is planned. On activation, stampTier writes tier on the new segment's customers and removes tier from customers outside the new ranking.

Accept only segment values that your app defines. Your app builds each value into ShopifyQL queries and target filters as a string.

Anchor to Close the campaign and record the revenueClose the campaign and record the revenue

After the campaign ends, rewrite the campaign's annotation with the campaign's revenue.

Anchor to Write the result onto the campaign's annotationWrite the result onto the campaign's annotation

Measure the campaign's sales from the start date through the close date, and compare the sales with the store-wide target. Then send the new title, description, and end time in one analyticsAnnotationUpdate call.

For example, the title Summer launch becomes Summer launch: 169% of target. Without a target, the title becomes Summer launch: $4,200 earned.


  • Truncate titles to 75 characters and descriptions to 150 characters.
  • When a user closes a campaign early, end each target on the close date and delete the annotations planned for days after the close date. A target that starts on or after the close date, such as a QR code target with a later Live on date, ends the day after the target starts.

Anchor to Split the campaign's revenue across the touchesSplit the campaign's revenue across the touches

A touch is a campaign event that reached the customer, such as a send, the creator drop, or the product promotion. After the campaign closes, write each order's last touch to the order's campaign_touch metafield.

Order attribution groups sales by touch:

ShopifyQL

FROM sales
SHOW order.metafields."app--{app_id}--campaign_dashboard".campaign_touch AS touch,
total_sales, orders
GROUP BY touch
SINCE 2026-07-22 UNTIL 2026-08-04
ORDER BY total_sales DESC

  • Group by order_id and day to date each order, and match each order to the last touch on or before the order's date.
  • Filter with excludes_post_order_adjustments = true, is_sales_reversal = false, and is_sale_adjustment = false. Shopify adds a sales row on the day of each refund or edit. Without the filters, a refund or edit row can date a pre-campaign order inside the campaign.
  • Page the query 250 orders at a time.
  • The write_orders scope covers only orders created in the last 60 days.

Anchor to Stamp the orders in a bulk operationStamp the orders in a bulk operation

A campaign can span thousands of orders. Write the campaign_touch metafields in one bulk operation. Upload a JSONL file with up to 25 metafields on each line. Then pass metafieldsSet to bulkOperationRunMutation.

When a user closes the campaign, your app starts the operation and saves the operation's ID in your app's database. OrderStamping polls the stamping route every five seconds. The route reads only the bulk operation and doesn't count against the store's ShopifyQL limit.

Read the operation's result file and report each rejected line's userErrors. The user can click Stamp orders again to rerun the operation, which writes the same value to every order in the campaign.


Anchor to Record the day a campaign meets a targetRecord the day a campaign meets a target

After sales reach the store-wide target, the user records the day with Mark milestone. Rerun the target's query to confirm that sales still meet the target, and then write a revenue_milestone annotation for today.


Run the app on your development store:

Terminal

shopify app dev

Under Analytics dimensions, click Create dimension on each row. On the product row, click Turn on analytics, and then turn on Filter or group data in Analytics.

Create a campaign with a Store sales target amount, and then click Activate campaign. The target cards show progress. The sales card shows the campaign's annotations.

Create a second campaign with no target amounts and a start date in the past. Your app closes only a campaign that has started. Click Activate campaign and Close campaign, and then confirm. The annotation's title changes to <campaign title>: <amount> earned.

Was this page helpful?