Billing, retries and statuses
Each period, one bill on its due date. Failures are retried; accessUntil says who gets access.
- trial → active: confirmed
- trial → trialExpired: trial ends
- trialExpired → active: confirmed
- active → paused: paused
- paused → active: resumed
- active → pastDue: bill fails
- pastDue → active: bill paid
- active → stopped: cancel
- paused → stopped: cancel
- pastDue → stopped: you stop it
- stopped → cancelled (final): period ends
- pastDue → cancelled (final): last retry fails
- trialExpired → cancelled (final): grace ends
- any status → cancelled (final): your cancel, closed, inactive
Each status change sends subscription.statusChanged, once. What the customer must do is apart,
in actionRequired (the allowance): an active subscription can
still need a top-up. Any status ends at once (cancelled) on an immediate cancel, a removed seller,
or a contract that no longer has it active.
Each period
| When | What happens | Webhook |
|---|---|---|
A few days before (subscriptions.upcomingBillDays; none for a period no longer than that) | a notice, saying whether the wallet can pay | subscription.upcomingBill |
nextBillingAt, within about 10 minutes (access holds meanwhile) | charged at the locked priceUsd plus its fees lines; currentPeriodEnd and accessUntil move on | subscription.billed (bill.periodStart → bill.periodEnd) |
| The charge fails | retries start; pastDue | subscription.billingFailed, then subscription.statusChanged |
| The last attempt fails | cancelled (cancellationReason: "billingFailed") | subscription.statusChanged |
QBitFlow's own trouble (a network or a price unavailable) is retried every 30 minutes: no attempt spent, nothing sent, nobody blamed.
In test mode a subscription is billed only when you ask: the test bill runs the next due bill now, with live's statuses and webhooks. Use the shortest test frequency (5 minutes).
subscriptions-test-bill.sh
# Test mode only: runs the next billing now, with live's statuses and webhooks.# 409 payment_not_due before nextBillingAt.curl -X POST "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID/test-bill" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a TestBill// Test mode only: runs the next billing now, with live's statuses and webhooks. A ConflictError// payment_not_due before nextBillingAt; a BadRequestError test_mode_only for a live one.const bill = await client. subscriptions. testBill( subscriptionUuid);// billUuid is sub-hist@…: the id its subscription.billed carries once paid.console.log(bill.billUuid, bill.stage, bill.outcome ?? 'running', bill.failureCode ?? '');# Test mode only: run the next bill now instead of on its due date.bill = client. subscriptions. test_bill( subscription_uuid)print(bill.bill_uuid, bill.stage, bill.outcome, bill.failure_code) # sub-hist@…subscriptions/main.go
// Test mode only: runs the next billing now, with live's statuses and webhooks.bill, err := client. Subscriptions. TestBill( ctx, subscriptionUUID)if err != nil { log.Fatal(err) // 409 payment_not_due before NextBillingAt; 400 test_mode_only for a live one}fmt.Println(bill.BillUUID, bill.Stage, bill.Outcome, bill.FailureCode) // sub-hist@…// Test mode only: runs the next billing now instead of on its due date.$bill = $client->subscriptions->testBill( $subscriptionUuid);echo "Bill {$bill->billUuid}: {$bill->stage} " . ($bill->outcome ?? '-') . ' ' . ($bill->failureCode ?? '') . "\n"; // sub-hist@…Retries (dunning)
- Attempt 1The bill fails: pastDue
- Day 1Attempt 2
- Day 3Attempt 3
- Day 5Attempt 4
- Day 7Attempt 5, the last
- CancelledIf the last one fails
A failed bill gets 5 attempts: the first, then 1, 3, 5 and 7 days
later; the last failing cancels it. Each failure sends subscription.billingFailed with its reason
(insufficientBalance, allowanceExhausted, approvalRevoked, maxAmountExceeded, other), attempt, remainingAttempts,
nextAttemptAt and the managementPageLink where your customer fixes it. In live, QBitFlow emails
them at each failure when it has their address.
- A top-up or a new maximum retries the bill at once, spending no attempt.
- Paid, it sends
subscription.billedand the subscription isactiveagain. - While a bill fails, subscription reads (not webhooks) carry
dunning:failedAttempts,remainingAttempts,failingSince,nextAttemptAt. - A bill above the maximum (
raiseMaximum) first waits up to 7 days for a new one, spending no attempt.
Grant and revoke access
accessUntil vs now
- trial: accessUntil is the trial's end, still ahead: access
- active: accessUntil is an hour after the period paid for, still ahead: access
- paused: accessUntil is the end of the period paid for, still ahead: access
- stopped: accessUntil is the end of the period paid for, still ahead: access
- pastDue: accessUntil is the start of the unpaid period, already past: no access
- trialExpired: accessUntil is the trial's end, already past: no access
- cancelled: accessUntil is when access ended, already past: no access
accessUntil is the paid-through date (currentPeriodEnd), an hour later while active: a renewal
is billed within minutes of its due date, and its subscriber keeps access meanwhile. Store it from
each event (every subscription event carries it; subscription.billed in data.subscription) and
compare it to now.
Avoid
Check status == "active"
stopped and paused keep their period) and serves a bill that's due.Do
Check now < accessUntil
currentPeriodEnd would cut subscribers off for a few minutes at each renewal.has-access.sh
# Grant access while now < accessUntil, whatever the status: a stopped or paused# subscription has paid for its period, an active one keeps an hour while its renewal confirms.curl "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: { "uuid": "sub@…", "status": "active", "currentPeriodEnd": "2026-10-31T12:00:00Z", "accessUntil": "2026-10-31T13:00:00Z", … }// Grant access while now < accessUntil, whatever the status: a stopped or paused// subscription has paid for its period, and an active one keeps it while its renewal is billed.const subscription = await client. subscriptions. get( subscriptionUuid);if (hasAccess(subscription)) { console.log('serve the premium content');} else { console.log('access ended: show the renewal page');}subscription = client. subscriptions. get( subscription_uuid)# now is before access_until, whatever the status: the end of the period paid for, plus an# hour for an active subscription while its renewal is billed.if subscription.has_access(): print("grant access")else: print("no access")subscriptions/main.go
// Access lasts while now < AccessUntil, whatever the status: the end of the period paid// for, plus an hour for an active subscription while its renewal is billed.sub, err := client.Subscriptions.Get(ctx, subscriptionUUID, nil)if err != nil { log.Fatal(err)}if sub.HasAccess(time.Now()) { // now before AccessUntil fmt.Println("access granted until", sub. AccessUntil. Format( time. RFC3339))} else { fmt.Println("no access")}$subscription = $client->subscriptions->get( $subscriptionUuid);// now < accessUntil, whatever the status: the end of the period paid for, plus an hour for an// active subscription while its renewal is billed.if ($subscription->hasAccess()) { echo "Access granted\n";} else { echo "No access: the paid period has ended\n";}During retries (pastDue) or a trial's grace (trialExpired), the rule says no; a grace period is your policy.
Cancelling
| Who | How | Becomes |
|---|---|---|
| Your customer | from their management page, signed | stopped until nextBillingAt, then cancelled (customer) |
| You, at period end | cancel, immediate=false (a trial answers 400: no paid period) | stopped, then cancelled (merchant); a pastDue one cancels at its bill |
| You, now | cancel, immediate=true (the default), any status | cancelled at once (merchant) |
| QBitFlow | the last failed bill, an unconfirmed trial, a removed seller, inactive on-chain | cancelled |
subscriptions-cancel.sh
# immediate=false stops it now (never billed again) and cancels it at the end of the period paid# for. 409 when it is already stopped or inactive.curl -X POST "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID/cancel?immediate=false" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a Cancelled, "status": "stopped". 202 while the on-chain cancellation still confirms# (subscription.statusChanged tells the end).// immediate: false stops it now (never billed again) and cancels it at the end of the period// paid for. A ConflictError when it is already stopped or inactive.const { subscription, pending } = await client. subscriptions. cancel( subscriptionUuid, { immediate: false,});if (pending) { // HTTP 202: the on-chain cancellation is still confirming. It goes on regardless: // subscription.statusChanged tells the end. console.log('cancellation confirming');} else { console.log(`now ${subscription.status}`); // stopped}result = client. subscriptions. cancel( subscription_uuid, immediate=False) # at period endif result.pending: # HTTP 202: still confirming on-chain; subscription.statusChanged tells the end. print("cancellation confirming")else: print("now", result.subscription.status) # stopped, cancelled at the period's endsubscriptions/main.go
// Immediate false: never billed again, cancelled when the paid period ends.res, err := client.Subscriptions.Cancel(ctx, subscriptionUUID, &qbitflow. CancelSubscriptionParams{ Immediate: new(false),})if err != nil { log.Fatal(err) // 409 subscription_already_stopped_or_inactive}if res.Pending { // HTTP 202: the on-chain cancellation is still confirming; subscription.statusChanged // tells the end. fmt.Println("cancellation confirming")} else { fmt.Println("now", res.Subscription.Status) // stopped}$result = $client->subscriptions->cancel( $subscriptionUuid, new \QBitFlow\Params\CancelSubscriptionParams( immediate: false));if ($result->pending) { // HTTP 202: the on-chain cancellation is still confirming; subscription.statusChanged tells the end. echo "Cancellation confirming\n";} else { echo "Now {$result->subscription->status}\n"; // stopped: cancelled at the end of the paid period}POST /subscriptions/200 once done, 202 while it confirms (the webhook tells the end). A stopped subscription is
never billed again. Your customer can pause and resume (paused); you can't pause one. A charge
already on its way may land just after: it's recorded (subscription.status: "cancelled"), grants
nothing, and can be refunded.