Bulk charge NO_ATTEMPT filter sometimes picks billing cycles that already have a failed attempt

Hi everyone,

We’re seeing subscriptionBillingCycleBulkSearch and subscriptionBillingCycleBulkCharge select billing cycles that already have a failed billing attempt, even though we pass billingAttemptStatus: NO_ATTEMPT. The bulk charge then creates a second attempt on those cycles. It happens to a small share of cycles, it has happened twice on the same production store, and we can’t reproduce it on demand. We’d like to know whether this is expected behaviour or a bug, and if it’s expected, what puts a cycle back into the NO_ATTEMPT state.

Our setup

  • Admin GraphQL API 2026-07
  • A daily job per shop runs a bulk search over the shop-local “yesterday + today” range, reads the results with subscriptionBillingCycleBulkResults, and then runs subscriptionBillingCycleBulkCharge over the same range with the same filters:
mutation SubscriptionBillingCycleBulkCharge($startDate: DateTime!, $endDate: DateTime!) {
  subscriptionBillingCycleBulkCharge(
    billingAttemptExpectedDateRange: { startDate: $startDate, endDate: $endDate }
    filters: {
      contractStatus: [ACTIVE]
      billingCycleStatus: [UNBILLED]
      billingAttemptStatus: NO_ATTEMPT
    }
  ) {
    job { id }
    userErrors { field message }
  }
}

The bulk search uses the same date range and the same three filters.

Because the range covers the previous day, the window contains yesterday’s cycles whose first attempt failed (they’re in our own dunning/retry flow). We rely on NO_ATTEMPT to keep those cycles out of the bulk charge, and for almost all of them it does.

What happened (2026-10-03)

  • Bulk search gid://shopify/Job/d4643bd5-2698-4419-84f2-2c1b036a1c1a, started 2026-10-03T04:05:31Z
  • We read its results via subscriptionBillingCycleBulkResults at 04:38:19Z. 6 of the returned cycles already had a failed billing attempt, created on 2026-10-02 between 04:41Z and 04:48Z, i.e. about 23 hours before the search started.
  • We started the bulk charge at 04:38:19Z. All 6 cycles got a new billing attempt at exactly 2026-10-03T04:38:27Z.
Contract Cycle index Expected date 1st attempt (failed) 2nd attempt (from bulk charge)
gid://shopify/SubscriptionContract/26883981577 4 2026-10-02T00:00:00Z 107028709641, 2026-10-02T04:41:42Z, INSUFFICIENT_FUNDS 107462754569, 04:38:27Z, succeeded
gid://shopify/SubscriptionContract/26953744649 4 2026-10-02T00:00:00Z 107045912841, 2026-10-02T04:42:30Z, PAYMENT_PROVIDER_IS_NOT_ENABLED 107462787337, 04:38:27Z, PAYMENT_PROVIDER_IS_NOT_ENABLED
gid://shopify/SubscriptionContract/26991198473 4 2026-10-02T00:00:00Z 107063345417, 2026-10-02T04:43:18Z, PAYMENT_PROVIDER_IS_NOT_ENABLED 107462852873, 04:38:27Z, succeeded
gid://shopify/SubscriptionContract/28128674057 3 2026-10-02T10:00:00Z 107162927369, 2026-10-02T04:47:03Z, CARD_DECLINED 107463049481, 04:38:27Z, succeeded
gid://shopify/SubscriptionContract/30505632009 2 2026-10-02T17:00:00Z 107175674121, 2026-10-02T04:47:30Z, PROCESSING_ERROR 107463115017, 04:38:27Z, succeeded
gid://shopify/SubscriptionContract/30842355977 2 2026-10-02T09:00:00Z 107178098953, 2026-10-02T04:47:35Z, DO_NOT_HONOR 107463180553, 04:38:27Z, succeeded

(Attempt IDs are gid://shopify/SubscriptionBillingAttempt/<id>.)

The part we can’t explain: the same window had about 220 other cycles in what looks like the same state: ACTIVE contract, UNBILLED, not skipped, exactly one failed attempt from the same 2026-10-02 run, with a similar spread of error codes and expected dates. Those were correctly left out of both the search and the charge. We compared the 6 against the rest on expected date, cycleStartAt/cycleEndAt, attempt timestamp, error code, edited and contract status, and none of these separate them.

It happened before (2026-09-03)

Same store, same job. 11 cycles whose first attempt failed on 2026-09-02 were re-attempted by the bulk charge on 2026-09-03 at 04:38:16Z–04:38:17Z, out of about 250 failures that day. Example contracts: 26966982921, 31920947465, 31157453065, 26980909321, 26982973705, 30938530057.

What we tried to reproduce it

  1. Re-running the search later. At 2026-10-03T10:03Z we ran the identical bulk search over the 2026-10-02 window. It returned 0 cycles. That included contract 26953744649, which is still UNBILLED, in range, and by then had two failed attempts. So at rest the filter behaves correctly.
  2. Development store. We charged three elapsed, unbilled cycles so they failed with INSUFFICIENT_INVENTORY. Then on one contract we changed the payment method (subscriptionContractUpdate → subscriptionDraftUpdate(paymentMethodId) → commit), on one we edited the note, and the third we left alone. A NO_ATTEMPT bulk search over their window excluded all three: right after the failure, right after the edits, and again 1 and 15 minutes later. (Before the charge, the same search did return them, so the setup itself was valid.)
  3. Payment-method changes in production. Our first guess was that customers had updated their payment method after the failure. Only one of the six affected customers has a replaced method (26991198473: old method revoked with reason MERGED at 2026-10-02T06:31:00Z). The other five still have their original, never-revoked method, the same as the cycles that were correctly excluded.

Questions

  1. Is billingAttemptStatus: NO_ATTEMPT supposed to exclude any cycle that has at least one billing attempt, including failed ones? The docs don’t say what counts as an attempt here.
  2. Is there something that makes a cycle with a failed attempt count as NO_ATTEMPT again (an expiry window, a payment-session or payment-group reset, a customer action, indexing lag), or is this a bug in the bulk search/charge index?
  3. Could someone look at the job above and the attempt IDs to see why these 6 cycles matched?

Until we understand this, we’ll check billingAttempts ourselves before charging. But that only works if we charge cycles one by one. As far as we know, the bulk charge can’t be limited to a specific set of cycles, so it re-selects on its own and still picks up these cycles.

Thanks!

Hi @mgtmn

NO_ATTEMPT is documented as selecting billing cycles with no billing attempts. The behavior you’ve described needs further investigation to establish why those cycles were selected.

To investigate the specific jobs and billing attempts, please contact Shopify Support while signed into your Partner account. Include a link to this thread and the affected store and app details. That lets us verify your access and continue the account-specific investigation privately.