---
title: Upgrade your app for discount rollouts
description: >-
  Learn when and how your app should adopt the rollouts connection on GraphQL
  Admin API discounts.
source_url:
  html: 'https://shopify.dev/docs/apps/build/discounts/rollouts'
  md: 'https://shopify.dev/docs/apps/build/discounts/rollouts.md'
api_name: admin
---

# 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`](https://shopify.dev/docs/api/admin-graphql/2026-10/connections/RolloutConnection) 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.

***

## What's changing

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 behavior | Discount `status` | What buyers get |
| - | - | - |
| A treatment activates the discount | `SCHEDULED` or `EXPIRED` | The buyers that treatment reaches get it |
| Treatments expire it for every buyer | `ACTIVE` | No buyer gets it |
| A treatment expires it for some buyers | `ACTIVE` | The 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.

### Apps 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.

***

## What's not changing

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.

***

## Who 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 discounts | Re-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](#keep-your-app-in-sync). |
| Shows discounts to merchants on its own surface | Show rollout information next to the discount, because `status` alone can contradict what buyers get. Refer to [Show rollout information](#show-rollout-information). |
| Promotes a discount to buyers, such as a sales channel or a marketing surface | Only promote a discount that reaches every buyer. One in a serving rollout might not. Refer to [If your app promotes discounts](#if-your-app-promotes-discounts). |
| Acts on a discount's `status` | Check for a serving rollout before you trust `status`. Refer to [Find the rollouts on a discount](#find-the-rollouts-on-a-discount). |
| Reports on discount performance | Read 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](#read-the-traffic-split). |
| Deletes local records when a read stops returning a discount | Before `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](#keep-your-app-in-sync). |
| Only writes discounts | No changes required. |

***

## Show 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](#if-your-app-promotes-discounts).

***

## Find 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:

* [`DiscountAutomaticApp`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountAutomaticApp)
* [`DiscountAutomaticBasic`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountAutomaticBasic)
* [`DiscountAutomaticBxgy`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountAutomaticBxgy)
* [`DiscountAutomaticFreeShipping`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountAutomaticFreeShipping)
* [`DiscountCodeApp`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountCodeApp)
* [`DiscountCodeBasic`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountCodeBasic)
* [`DiscountCodeBxgy`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountCodeBxgy)
* [`DiscountCodeFreeShipping`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DiscountCodeFreeShipping)

## GraphQL query

```graphql
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
          }
        }
      }
    }
  }
}
```

### Add 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](https://shopify.dev/docs/apps/build/authentication-authorization/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.

### Filter 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`](https://shopify.dev/docs/api/admin-graphql/2026-10/enums/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`.

### Page 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](https://shopify.dev/docs/api/usage/pagination-graphql).

### Read 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`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/Rollout).

***

## Tell 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`:

* [`RolloutDiscountActivateChange`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/RolloutDiscountActivateChange): The treatment activates the discount for the buyers it reaches, whatever the discount's own `startsAt`, `endsAt`, or `status`.
* [`RolloutDiscountExpireChange`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/RolloutDiscountExpireChange): The treatment expires the discount for the buyers it reaches.

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

```graphql
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
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
```

### Build 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.

***

## Read the traffic split

A [`Rollout`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/Rollout) carries two percentages, and the difference matters. `trafficAllocation` is what the merchant configured, and `split` on each [`RolloutTreatment`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/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](#if-your-app-promotes-discounts).

***

## If 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.

***

## Keep 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.

### Subscribe to rollout webhooks

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

| Topic | Occurs when |
| - | - |
| `rollouts/create` | A rollout is created, including its initial treatments and attached changes. |
| `rollouts/update` | A 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/delete` | A rollout is deleted. Archiving a rollout produces `rollouts/update` instead. |
| `rollouts/resource_change_added` | A change is added to a treatment. This doesn't mean the resource was created or activated. |
| `rollouts/resource_change_removed` | A change is removed from a treatment. Other changes can still reference the same resource. |
| `rollouts/resource_change_updated` | A 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](https://shopify.dev/docs/apps/build/webhooks/subscribe).

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.

### Reconcile as well

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.

***

## Test your integration

1. [Create a dev store](https://shopify.dev/docs/apps/build/stores/development-stores) and install your app on it.
2. Set your app's [API version](https://shopify.dev/docs/api/usage/versioning) 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.

***
