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 runssubscriptionBillingCycleBulkChargeover 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
subscriptionBillingCycleBulkResultsat 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
- 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. - 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. ANO_ATTEMPTbulk 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.) - 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 reasonMERGEDat 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
- Is
billingAttemptStatus: NO_ATTEMPTsupposed 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. - Is there something that makes a cycle with a failed attempt count as
NO_ATTEMPTagain (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? - 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!