Concepts

Billing, retries and statuses

Each period, one bill on its due date. Failures are retried; accessUntil says who gets access.

  1. trial → active: confirmed
  2. trial → trialExpired: trial ends
  3. trialExpired → active: confirmed
  4. active → paused: paused
  5. paused → active: resumed
  6. active → pastDue: bill fails
  7. pastDue → active: bill paid
  8. active → stopped: cancel
  9. paused → stopped: cancel
  10. pastDue → stopped: you stop it
  11. stopped → cancelled (final): period ends
  12. pastDue → cancelled (final): last retry fails
  13. trialExpired → cancelled (final): grace ends
  14. 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

WhenWhat happensWebhook
A few days before (subscriptions.upcomingBillDays; none for a period no longer than that)a notice, saying whether the wallet can paysubscription.upcomingBill
nextBillingAt, within about 10 minutes (access holds meanwhile)charged at the locked priceUsd plus its fees lines; currentPeriodEnd and accessUntil move onsubscription.billed (bill.periodStart → bill.periodEnd)
The charge failsretries start; pastDuesubscription.billingFailed, then subscription.statusChanged
The last attempt failscancelled (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).

Bill a test subscription now

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@…
// 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)

  1. Attempt 1The bill fails: pastDue
  2. Day 1Attempt 2
  3. Day 3Attempt 3
  4. Day 5Attempt 4
  5. Day 7Attempt 5, the last
  6. 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.billed and the subscription is active again.
  • 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
Each track ends at accessUntil: the paid-through date, an hour later while active (its renewal on its way). A grace period after a past one is your call.

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"

It cuts off customers who paid (stopped and paused keep their period) and serves a bill that's due.

Do

Check now < accessUntil

Whatever the status. currentPeriodEnd would cut subscribers off for a few minutes at each renewal.
Check access to a subscription

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")
// 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

WhoHowBecomes
Your customerfrom their management page, signedstopped until nextBillingAt, then cancelled (customer)
You, at period endcancel, immediate=false (a trial answers 400: no paid period)stopped, then cancelled (merchant); a pastDue one cancels at its bill
You, nowcancel, immediate=true (the default), any statuscancelled at once (merchant)
QBitFlowthe last failed bill, an unconfirmed trial, a removed seller, inactive on-chaincancelled
Cancel a subscription

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 end
// 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/:uuid/cancel cancels on-chain: 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.