Skip to main content

Upgrade your app for discount rollouts

Merchants can include a discount in a rollout to coordinate a campaign launch, test the discount with a share of buyers, or schedule a temporary event alongside other store changes.

A rollout has its own schedule. While it's serving, a treatment can add the discount for the buyers it reaches, or remove it for them. A serving rollout doesn't change the discount's own start and end dates, or its configuration.

Version 2026-10 of the GraphQL Admin API adds a read-only rollouts connection to discounts. Your existing queries keep working. What changes is that a discount's own fields don't tell you what a rollout is doing to it.


A discount in a rollout is returned like any other discount. Its status still comes from its own startsAt and endsAt, so while a rollout is serving, status and what buyers get can differ:

Treatment behaviorDiscount statusWhat buyers get
A treatment activates the discountSCHEDULED or EXPIREDThe buyers that treatment reaches get it
Treatments expire it for every buyerACTIVENo buyer gets it
A treatment expires it for some buyersACTIVEThe buyers that treatment reaches don't get it

A treatment that makes no discount change leaves its buyers on the discount's own dates. Read the rollouts connection when you need a discount's effective availability or reach.

Note

Only a serving rollout changes what buyers get. While a rollout is draft, scheduled, paused, concluded, or archived, the discount's status is accurate.

Anchor to Apps on earlier API versionsApps on earlier API versions

Earlier versions can't represent a rollout, so they leave out the discounts that the three cases above apply to. Every other discount in a rollout is returned as usual, so being in a rollout doesn't make a discount unreadable.

An excluded discount is missing from discountNode lookups by ID, from the discountNodes, codeDiscountNodes, automaticDiscountNodes, and automaticDiscounts connections, from discountNodesCount, and from the discount connections and counts on Market. Nothing in the response says why, and those versions have no rollouts connection to explain it. The discount returns when the rollout stops affecting it.

Upgrading to 2026-10 removes the exclusion. The discount stays readable, and the rollouts connection tells you what the rollout is doing to it.


Nothing in the schema is removed, renamed, or retyped. A serving rollout doesn't change a discount's startsAt, endsAt, or configuration, or the behavior of the status filters on the discount connections.


Anchor to Who needs to take actionWho needs to take action

Find your app's behavior below, and follow the path for each one that applies:

If your app...Action required
Caches, mirrors, or syncs discountsRe-read a discount when its rollout starts or stops serving. Its own fields don't change while the rollout serves, so a stale copy still looks current. Refer to Keep your app in sync.
Shows discounts to merchants on its own surfaceShow rollout information next to the discount, because status alone can contradict what buyers get. Refer to Show rollout information.
Promotes a discount to buyers, such as a sales channel or a marketing surfaceOnly promote a discount that reaches every buyer. One in a serving rollout might not. Refer to If your app promotes discounts.
Acts on a discount's statusCheck for a serving rollout before you trust status. Refer to Find the rollouts on a discount.
Reports on discount performanceRead the rollout's effective traffic allocation first. A discount in a rollout can reach only part of the store, so crediting it with store-wide results overstates it. Refer to Read the traffic split.
Deletes local records when a read stops returning a discountBefore 2026-10, a rollout can take a discount out of your reads. Treat an absent discount as unavailable, not deleted. Refer to Keep your app in sync.
Only writes discountsNo changes required.

Anchor to Show rollout informationShow rollout information

A discount can belong to a rollout that hasn't started yet, or to one that has already concluded. Where a merchant benefits from seeing that, such as a discount list or a performance report, show the rollout's name, status, and traffic allocation next to the discount.

Where your surface tells a buyer what they get, such as a sales channel or a storefront display, don't fall back on the discount's status: a serving rollout can activate a discount whose own dates say it's over, or expire one whose dates say it's live. Refer to If your app promotes discounts.


Anchor to Find the rollouts on a discountFind the rollouts on a discount

The rollouts connection lists the rollouts that a discount belongs to, and their scheduled start and end times. Each of these types implements the HasRollouts interface, so you can write the selection once as a fragment and reuse it. Query it on:

GraphQL query

query DiscountRollouts($id: ID!, $cursor: String) {
discountNode(id: $id) {
discount {
... on DiscountCodeBasic {
title
status
rollouts(first: 10, after: $cursor, query: "status:ACTIVE") {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
name
status
schedule {
activateAt
concludeAt
}
startedAt
concludedAt
effectiveTrafficAllocation
}
}
}
}
}
}

Anchor to Add the ,[object Object], access scopeAdd the read_rollouts access scope

This path requires the read_rollouts access scope, on top of the read_discounts scope that your app already has. Refer to Manage access scopes. A merchant has to grant the new scope, so existing installations might need to be reauthorized.

Add the rollouts selection behind a check for the scope, rather than into a query that your app already depends on. Without the scope, the request returns an ACCESS_DENIED error, and because the fields in that path are non-null, the error propagates up to discountNode, which comes back as null. A missing scope takes the discount fields with it.

Anchor to Filter the connectionFilter the connection

Unfiltered, rollouts isn't limited to the rollouts serving right now: it also returns drafts and concluded ones. Use query to filter by status, as in status:ACTIVE,SCHEDULED. For the values, refer to RolloutStatus. A comma-separated list is an OR, so status:ACTIVE,SCHEDULED and status:ACTIVE OR status:SCHEDULED return the same rollouts, and either form combines with AND, OR, and NOT.

Anchor to Page through the resultsPage through the results

rollouts is a connection, and how many rollouts it returns can change, so don't read the first node and stop.

Request the page size that suits your app, then read pageInfo.hasNextPage and fetch the next page with pageInfo.endCursor for as long as it's true. With one rollout, hasNextPage is false and you make no second request. For the pattern, refer to Paginating results with GraphQL.

Anchor to Read a rollout's scheduleRead a rollout's schedule

Read status to tell whether a rollout is serving now, and check it against schedule.concludeAt: for a short window after the conclude time has passed, status can still read ACTIVE for a rollout that has stopped applying its changes. To schedule your own refresh for a rollout that hasn't started yet, read schedule, which holds the planned activateAt and concludeAt and is null for a rollout with no planned dates. Those are the planned dates, separate from startedAt and concludedAt, which record what actually happened. For every field on a rollout, refer to Rollout.


Anchor to Tell an activation from an expirationTell an activation from an expiration

A rollout includes a discount through a change on one of its treatments. Each treatment lists the changes it makes, and each change has a type. Read a change's __typename:

Not every treatment carries a discount change, and a treatment can change resources other than discounts, such as catalogs, themes, and checkout and accounts configuration. Filter the changes to the discount you're reading, and check the type of what comes back:

GraphQL query

query DiscountRolloutChanges($id: ID!, $changeFilter: String!) {
discountNode(id: $id) {
discount {
... on DiscountAutomaticBasic {
rollouts(first: 10, query: "status:ACTIVE") {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
name
treatments {
id
split
changes(first: 5, query: $changeFilter) {
pageInfo {
hasNextPage
endCursor
}
nodes {
__typename
id
... on RolloutDiscountChange {
discount {
id
}
}
}
}
}
}
}
}
}
}
}

Anchor to Build the change filterBuild the change filter

Build $changeFilter in your own code. discount_id takes a numeric ID unquoted, as in discount_id:1234567890, or a GID in quotes, as in discount_id:'gid://shopify/DiscountNode/1234567890'. type takes a change typename, such as type:RolloutDiscountActivateChange; it's case-sensitive and can be quoted or not.

To return only the changes that affect the discount you're reading:

discount_id:1234567890

Combine terms with AND, OR, or whitespace, which reads as AND, and group them with parentheses. To narrow that to one kind of change:

type:RolloutDiscountActivateChange AND discount_id:1234567890

Negation, ranges, and wildcards aren't supported. A malformed expression returns an error rather than an unfiltered list.

Caution

A discount_id filter matches only discounts your app can read and that are available, so an inaccessible discount returns no changes rather than an error. A type filter has no such restriction and keeps the retained identity of a change whose resource your app can't read, so use type when you need to see that a change exists at all.


Anchor to Read the traffic splitRead the traffic split

A Rollout carries two percentages, and the difference matters. trafficAllocation is what the merchant configured, and split on each RolloutTreatment divides it between the treatments. effectiveTrafficAllocation is the share of new buyer assignments the rollout actually receives once Shopify resolves conflicts with other active rollouts on the same resources, so it can be lower.

Neither is a measured share of buyers, and neither accounts for buyer eligibility or how a discount applies in a particular market. Use them to tell a merchant that a discount is partial, not to calculate exact reach. To decide whether a discount reaches every buyer, refer to If your app promotes discounts.


Anchor to If your app promotes discountsIf your app promotes discounts

If your app tells buyers that a discount is available, only promote one that every buyer can use. A discount in a serving rollout can reach part of the store, and nothing on the discount itself says so.

A discount is in at most one serving rollout at a time. When it's in one, treat the discount as safe to promote only when both of these hold. These conditions are about the rollout, so the discount's own eligibility rules still decide whether a given buyer can use it:

  • The rollout's effectiveTrafficAllocation is 100. Read the effective allocation, not trafficAllocation: a rollout configured at 100% can still come out lower once Shopify resolves conflicts with other active rollouts.
  • One treatment activates the discount, and that treatment's split is 100.

Those two conditions are the simple safe case, and the allocation alone isn't enough to establish it: a rollout running a control against a treatment can sit at 100 and still hand the discount to one arm's buyers only.

They also assume a rollout that changes discounts and nothing else. effectiveTrafficAllocation covers everything a rollout changes, not the discount on its own, so where showing a discount to a buyer who can't use it is costly, treat these conditions as a strong signal rather than a guarantee.

Beyond that case, work out what each arm does, because a buyer is assigned to exactly one treatment. An arm that expires the discount denies it to its buyers. An arm that makes no change leaves them on the discount's own dates, so they still get it if the discount is active in its own right.

When you can't see the whole picture, treat the discount as restricted rather than assuming. That includes hasNextPage being true on rollouts or on a treatment's changes, and a change whose resource your app can't read.


Anchor to Keep your app in syncKeep your app in sync

On 2026-10, a discount in a rollout stays readable, and while the rollout is serving, none of the discount's own fields change. A cached copy looks current after what buyers get has changed.

Anchor to Subscribe to rollout webhooksSubscribe to rollout webhooks

Version 2026-10 adds six rollout topics. Subscribe to them to hear about a change as it happens:

TopicOccurs when
rollouts/createA rollout is created, including its initial treatments and attached changes.
rollouts/updateA rollout changes lifecycle status, or its configuration, treatments, or attached changes are updated. A rollout also receives this topic when a competing rollout changes its effective traffic allocation, even though nothing about the rollout itself changed.
rollouts/deleteA rollout is deleted. Archiving a rollout produces rollouts/update instead.
rollouts/resource_change_addedA change is added to a treatment. This doesn't mean the resource was created or activated.
rollouts/resource_change_removedA change is removed from a treatment. Other changes can still reference the same resource.
rollouts/resource_change_updatedA change in a treatment is updated, which today means it targets a different resource. The change ID stays the same.

These topics need the read_rollouts access scope, and only an app can subscribe to them: they don't appear in a store's notification settings. To subscribe, refer to Subscribe to webhook topics.

Treat a payload as a notification rather than a source of truth. Every payload identifies the rollout with admin_graphql_api_id, and there are three shapes behind that:

  • rollouts/create and rollouts/update carry the rollout's own fields, such as its name, status, schedule, and both its configured and effective traffic allocation.
  • A resource_change_* payload carries identifiers only: the treatment, the change, the resource it points at, and occurred_at. It has no rollout fields, so don't decode it with the reader you use for a rollout snapshot.
  • rollouts/delete carries admin_graphql_api_id and deleted_at.

No payload carries the rollout's treatments or its changes. Re-read the discount's rollouts connection to see what the rollout now does to the discount.

A delivery can be missed, so don't let a webhook be the only thing that updates your copy. Reconcile periodically. If you don't subscribe, use the rollout's schedule instead: query rollouts with status:ACTIVE,SCHEDULED and refresh at schedule.activateAt and schedule.concludeAt. A merchant can also pause or conclude a rollout early, which the authored schedule won't tell you. Effective traffic allocation is worked out against the other rollouts running at the time, so don't assume a stored copy is still current when you haven't processed a recent rollouts/update.

Never delete a local record because a discount stopped appearing in a read. An absent discount isn't a deleted one.

On an API version earlier than 2026-10, you can't tell the two apart: a rollout takes the discount out of your reads, and nothing in the response says so. Upgrading to 2026-10 is the fix, because the discount stays readable and the rollouts connection tells you what the rollout is doing to it. Until you upgrade, keep the local record and re-read later instead of deleting it.


Anchor to Test your integrationTest your integration

  1. Create a dev store and install your app on it.
  2. Set your app's API version to 2026-10.
  3. Create a discount, then put it in a rollout from the Shopify admin. Configure the rollout to serve part of the store's traffic.
  4. Read the discount and confirm that your app handles the rollouts connection and each treatment's changes.
  5. Activate the rollout with a discount whose own dates make it scheduled or expired, then confirm that your app still reports the discount as reaching buyers.
  6. Pause the rollout, then conclude it. Confirm that your app re-reads the discount instead of trusting a cached response.

Was this page helpful?