Syncing Database State with the new Shopify App Pricing API (Without Webhooks)

Given that the new Shopify App Pricing model deprecates APP_SUBSCRIPTIONS_UPDATE webhooks, what is the most robust caching strategy and polling frequency for the Partner API (activeSubscription and events queries) to keep our local database synced, without exhausting rate limits, while ensuring out-of-band events like payment freezes are caught promptly?

Hey @Amiya_Soni - thanks for raising this.

We currently don’t have a specific recommended polling interval or cache TTL for activeSubscription or events, but the Partner API limit is four requests per second per client, so the right cadence depends on your install volume and acceptable sync delay.

My basic recommendation would be to query activeSubscription after plan redirects and when cached state is stale. For freezes and cancellations, I’d poll events at the app level instead of polling every shop individually, then run a slower reconciliation job to catch any drift.

Hope this helps a bit - let me know if I can clarify anything on our end here.

Thanks @Alan_G for suggesting me this solution.

We migrated off per-shop polling of activeSubscription entirely and now rely only on the app-level events query on App, filtered to the subscription lifecycle event types (charge activated, canceled, frozen, unfrozen, declined, expired). We run exactly two jobs, both against that same events query — just a different lookback window and cadence:

  • Real-time job: runs every 15 minutes, looking back 15 minutes, with a small overlap so a slow tick can’t create a coverage gap.
  • Reconciliation job: runs once a week, looking back 7 days, same overlap reasoning.

For every shop ID returned by either job, we then fetch that shop’s activeSubscription individually and update our cached state from it.

We deliberately do not loop through every installed shop and call activeSubscription for each one — the two event-driven jobs above are our only reconciliation mechanisms.

Question: Is this event-driven approach (real-time plus weekly, both scoped to the events query, no full per-shop sweep) sufficient to keep subscription state in sync, or is a periodic full per-shop activeSubscription loop still recommended as a backstop — for example, to catch a plan change our app never received an event for, whether from an event type we don’t listen for, an API outage during a poll window, or some other gap between the events feed and true subscription state?

If a full per-shop reconciliation is still recommended, how frequently should it run, and is there a cheaper way to detect that a shop’s state may have drifted than calling activeSubscription for every single installed shop?
@Alan_G

Hey @Amiya_Soni - I’d keep a periodic live-state check as a backstop, but there’s one query detail to confirm first.

Your description sounds like the older App.events query with SUBSCRIPTION_CHARGE_* types. For Shopify App Pricing, the recommended approach is the root events query, filtered to your app using subjectId. It uses different lifecycle types, including SUBSCRIPTION_UPDATED for plan changes and SUBSCRIPTION_CANCELLATION_SCHEDULED for scheduled cancellations:
Shopify App Pricing

I should also clarify my earlier reconciliation suggestion as well: your weekly replay can recover matching events missed by the frequent job, but it won’t detect changes excluded from that feed or filter. Fetching activeSubscription only for shops appearing in the feed leaves that same gap.

I’d recommend:

  • Keeping event-driven refreshes, plus checks after plan redirects and when cached state is stale on app access.
  • Adding a rolling activeSubscription check across installed shops, starting with those least recently verified. Choose the interval based on your acceptable sync delay; this is a defensive recommendation, not a documented Shopify requirement or fixed
    cadence.
  • Resuming polls from the last fully processed window, paginating completely, and retrying failed refreshes so outages don’t leave gaps.

Spreading checks over time helps stay within the shared four-requests-per-second-per-client limit.

Could you share your current GraphQL query, with sensitive values removed, and Partner API version? That’ll help confirm which feed you’re polling and we can take a closer look - hope this helps!

{
  app(id: "gid://shopify/App/<APP_ID>") {
    events(
      types: [
        SUBSCRIPTION_CHARGE_ACTIVATED
        SUBSCRIPTION_CHARGE_CANCELED
        SUBSCRIPTION_CHARGE_FROZEN
        SUBSCRIPTION_CHARGE_UNFROZEN
        SUBSCRIPTION_CHARGE_DECLINED
        SUBSCRIPTION_CHARGE_EXPIRED
      ]
      occurredAtMin: "<ISO_TIMESTAMP>"
      first: 100
    ) {
      edges {
        node {
          ... on AppEvent {
            occurredAt
            type
          }
          ... on SubscriptionChargeActivated {
            shop { id }
          }
          ... on SubscriptionChargeCanceled {
            shop { id }
          }
          ... on SubscriptionChargeFrozen {
            shop { id }
          }
          ... on SubscriptionChargeUnfrozen {
            shop { id }
          }
          ... on SubscriptionChargeDeclined {
            shop { id }
          }
          ... on SubscriptionChargeExpired {
            shop { id }
          }
        }
      }
    }
  }
}

Endpoint: https://partners.shopify.com/{org_id}/api/2026-07/graphql.json
Partner API version: 2026-07
@Alan_G

Hey @Amiya_Soni - thanks for sharing this. That confirms you’re using the older app.events query I mentioned above. Your 2026-07 endpoint is fine; for Shopify App Pricing, it’s the query and event types that need changing.

For your subscription polling jobs, use the root events query like this:

query SubscriptionEvents($appId: ID!, $from: DateTime!, $to: DateTime!, $after: String) {
  events(
    filter: {
      subjectId: $appId
      subjectType: APP
      eventTypes: [
        SUBSCRIPTION_CREATED
        SUBSCRIPTION_UPDATED
        SUBSCRIPTION_CANCELLATION_SCHEDULED
        SUBSCRIPTION_CANCELED
        SUBSCRIPTION_FROZEN
        SUBSCRIPTION_UNFROZEN
      ]
      occurredAtMin: $from
      occurredAtMax: $to
    }
    orderBy: OCCURRED_AT_ASC
    first: 250
    after: $after
  ) {
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      node {
        id
        occurredAt
        eventType
        shop { id }
        ... on SubscriptionStatus {
          state
          cancelEffectiveOn
        }
      }
    }
  }
}

Set $appId to gid://shopify/App/<APP_ID>, $from and $to to the ISO timestamps for your polling window, and $after to null for the first request.

The other thing I’d check is pagination. Your example stops at first: 100, so if that’s the complete production query, either job can miss events beyond that first page. With the query above, pass endCursor into $after while hasNextPage is true, keeping the same $from and $to across all pages.

The filtering and pagination details are here:

For recovery, I’d resume from the last fully processed window rather than just looking back 15 minutes from each run. Deduplicate overlapping events by id, and make sure failed shop refreshes stay queued for retry.

I’d still keep the rolling activeSubscription checks from my earlier reply as a backstop. The weekly replay is useful, but it isn’t an independent check of current state. There’s no documented daily or weekly requirement here; choose the interval based on how long you can tolerate stale data. If you still have merchants on legacy Billing API subscriptions, keep that handling in place for them too.

Could you try this against a known plan change and let me know whether you see the SUBSCRIPTION_UPDATED event? That’ll give us a concrete example to check if anything is still missing on our end.

Thanks @Alan_G, this is really helpful — confirmed on our end.

We updated both jobs to the root events query with subjectId/subjectType: APP, the corrected event types, and full pagination via hasNextPage/endCursor. Triggered a real plan change on a test shop in dev and confirmed we’re now seeing SUBSCRIPTION_UPDATED come through correctly with the right shop attached — the old query was indeed the problem, it wasn’t surfacing anything for App Pricing changes.

We’ve also added the rolling activeSubscription sweep as an independent backstop, oldest-verified-shop-first, resuming from where the last run left off rather than restarting each time — running it alongside our existing weekly events-replay for now, and we’ll tighten the interval if/when shop count grows enough to need it.

Really appreciate you taking the time to walk through this in detail — saved us from shipping on a feed that wasn’t actually firing for our case.
Here’s the query we’re running now:

query SubscriptionEvents($appId: ID!, $from: DateTime!, $to: DateTime!, $after: String) {
  events(
    filter: {
      subjectId: $appId
      subjectType: APP
      eventTypes: [
        SUBSCRIPTION_CREATED
        SUBSCRIPTION_UPDATED
        SUBSCRIPTION_CANCELLATION_SCHEDULED
        SUBSCRIPTION_CANCELED
        SUBSCRIPTION_FROZEN
        SUBSCRIPTION_UNFROZEN
      ]
      occurredAtMin: $from
      occurredAtMax: $to
    }
    orderBy: OCCURRED_AT_ASC
    first: 250
    after: $after
  ) {
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      node {
        id
        occurredAt
        eventType
        shop { id }
        ... on SubscriptionStatus {
          state
          cancelEffectiveOn
        }
      }
    }
  }
}

Hey @Amiya_Soni - no worries at all, glad that’s working now! Thanks for confirming and sharing the updated query. Let me know if I can help out further.