Cancel and refund

End a subscription now or at the end of its paid period, and refund a bill.

A subscription ends at once (cancelled) or at the end of the period paid for (stopped, then cancelled). Either way it's cancelled on-chain, so nothing can bill it again, and subscription.statusChanged tells you each change.

  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

Cancel a subscription

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}
  • immediate: true (the default): cancelled at once, whatever its status.
  • immediate: false: stopped now, cancelled at its nextBillingAt; the customer keeps the period paid for. A pastDue one has none left: its due bill cancels it right away. Already stopped: 409 (send immediate: true to end it now).
  • A trial is always cancelled at once (immediate: false answers 400).
  • It answers the subscription: 200 once done, 202 while the on-chain cancellation confirms (the status changes once it's confirmed; subscription.statusChanged tells you).

Others end it too. The customer, from their page: stopped (cancellationReason: "customer"), never billed again, cancelled at the end of its paid period. QBitFlow: after the last failed bill, an unconfirmed trial 7 days after its end (when the trial ends), a merchant leaving (a seller removed, an organization closed), or a subscription no longer active on-chain.

Watch out

A bill can land just after a cancellation

A charge already on its way may still confirm: subscription.billed with subscription.status: "cancelled". It stays cancelled and no access is due; refund it if your policy says so.

Check it

Cancel a test subscription without a trial with immediate: false: once 200, it reads stopped, subscription.statusChanged (active → stopped) shows in Dashboard/Settings/Events, and access runs until accessUntil (its currentPeriodEnd: a stopped subscription isn't renewed).

Tag a subscription

Your metadata merges into the subscription's, and it's the only field you edit: its price, terms and status stay as signed.

Tag a subscription with metadata

subscriptions-update.sh

# Merges metadata into the subscription: a key given with an empty value is deleted, the others# are kept. It is the only field a merchant edits.curl -X PATCH "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID" \  -H "X-API-Key: $QBITFLOW_API_KEY" \  -H "Content-Type: application/json" \  -d '{    "metadata": { "plan": "pro", "legacyKey": "" }  }'# 200: a Subscription
// Metadata is merged into the subscription's: '' deletes a key, keys not named are kept. It// is the only thing a merchant edits on a subscription; webhooks carry it.const updated = await client.subscriptions.update(subscriptionUuid, {	metadata: { plan: 'pro', coupon: '' },});console.log(updated.metadata.plan);
# Metadata is merged into the subscription's: "" deletes a key, keys not named are kept.# It is the only thing a merchant edits on a subscription; webhooks carry it.updated = client.subscriptions.update(subscription_uuid, metadata={"plan": "pro", "coupon": ""})print(updated.metadata.get("plan"))
// Metadata is merged into the subscription's: "" deletes a key, keys not named are kept.// It is the only thing a merchant edits on a subscription; webhooks carry it.updated, err := client.Subscriptions.Update(ctx, subscriptionUUID, &qbitflow.UpdateSubscriptionParams{	Metadata: map[string]string{"plan": "pro", "coupon": ""},})if err != nil {	log.Fatal(err)}fmt.Println(updated.Metadata["plan"])
// Metadata is merged into the subscription's: '' deletes a key, keys not named are kept. It is the// only thing a merchant edits on a subscription; webhooks carry it.$updated = $client->subscriptions->update($subscriptionUuid, new \QBitFlow\Params\UpdateSubscriptionParams(	metadata: ['plan' => 'pro', 'coupon' => ''],));echo ($updated->metadata['plan'] ?? '-') . "\n";

Refund a bill

Each paid period is a bill with its own id, sub-hist@…, refunded like a payment, from the dashboard: Dashboard/Payments/a bill/Refund. You sign with the wallet that was paid, holding the share (as for a payment). Once the transfer confirms, the refund is approved and refund.approved arrives. A refund doesn't cancel the subscription: cancel it too if that's what you mean.

List refunds

refunds-list.sh

# The refunds waiting for an answer.curl "https://api.qbitflow.app/v2/refunds?status=pending" \  -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a page of Refund ({ items, hasMore, nextCursor? })
// The active ones: pending, waiting for an answer (no status: every status).const page = await client.refunds.list({ status: [RefundStatus.Pending] });for (const refund of page.items) {	const { uuid, txUuid, initiatedBy, reason, amount, currency } = refund;	console.log(`${uuid} of ${txUuid} (${initiatedBy}): ${amount} ${currency.symbol}, ${reason}`);}
# The ones waiting for an answer (status None: every status).page = client.refunds.list(status=[qbitflow.RefundStatus.PENDING])for refund in page.items:    print(refund.uuid, refund.tx_uuid, refund.initiated_by, refund.reason, refund.amount)
// The active ones: pending, waiting for an answer (Status empty: every status).page, err := client.Refunds.List(ctx, &qbitflow.RefundListParams{	Status: []qbitflow.RefundStatus{qbitflow.RefundStatusPending},})if err != nil {	log.Fatal(err)}for _, r := range page.Items {	fmt.Println(r.UUID, r.TxUUID, r.InitiatedBy, r.Reason, r.Amount, r.Currency.Symbol)}
// The active ones: pending, waiting for an answer (no status: every status).$page = $client->refunds->list(new \QBitFlow\Params\RefundListParams(	status: [\QBitFlow\Enums\RefundStatus::PENDING],));foreach ($page->items as $refund) {	echo "{$refund->uuid} {$refund->txUuid} ({$refund->initiatedBy}): {$refund->amount} {$refund->currency->symbol}, " . ($refund->reason ?? '-') . "\n";}
Read a refund

refunds-get.sh

# One refund, by its id (refund@…): its status, its share of the payment and its amount.curl "https://api.qbitflow.app/v2/refunds/$REFUND_UUID" \  -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a Refund
const refund = await client.refunds.get(refundUuid); // refund@…// pending (a customer's request), approved or rejected.const { uuid, status, refundPercent, amount, currency } = refund;console.log(`${uuid}: ${status}, ${refundPercent}% = ${amount} ${currency.symbol}`);
refund = client.refunds.get(refund_uuid)  # refund@…# pending (a customer's request), approved or rejected.print(refund.uuid, refund.status, f"{refund.refund_percent}%", refund.amount)
refund, err := client.Refunds.Get(ctx, refundUUID) // refund@…if err != nil {	log.Fatal(err)}// pending (a customer's request), approved or rejected.fmt.Println(refund.UUID, refund.Status, refund.RefundPercent+"%", refund.Amount, refund.Currency.Symbol)
$refund = $client->refunds->get($refundUuid); // refund@…// pending (a customer's request), approved or rejected.echo "{$refund->uuid} {$refund->status} {$refund->refundPercent}% {$refund->amount} {$refund->currency->symbol}\n";

subscription.billed· the body your endpoint receives

{  "createdAt": "2026-10-01T12:00:00Z",  "data": {    "bill": {      "amount": "10",      "amountMinUnits": "10000000",      "amountUsd": "10",      "chain": "BASE",      "confirmedAt": "2026-10-01T11:59:58Z",      "createdAt": "2026-10-01T12:00:00Z",      "currency": {…},      "customerReference": "crm-5521",      "customerUuid": "01a05cd7-2a00-7d00-8000-0000000000d1",      "explorerUrl": "https://basescan.org/tx/0x3333333333333333333333333333333333333333333333333333333333333333",      "from": "0xabcdef1234567890abcdef1234567890abcdef12",      "paid": "10.0042",      "paidMinUnits": "10004200",      "paidUsd": "10.0042",      "periodEnd": "2026-10-31T12:00:00Z",      "periodStart": "2026-10-01T12:00:00Z",      "productDescription": "Monthly access to the Pro plan",      "productName": "Pro plan",      "productUuid": "019cadfd-8900-7c00-8000-0000000000c1",      "settlement": {…},      "subscriptionUuid": "sub@01a0f755-c320-7000-8000-000000000002",      "test": false,      "to": "0x1234567890abcdef1234567890abcdef12345678",      "txHash": "0x3333333333333333333333333333333333333333333333333333333333333333",      "uuid": "sub-hist@01a0f755-f200-7000-8000-000000000005"    },    "subscription": {…}  },  "id": "evt_bca1239f-e7d7-517c-b004-08aaeee1e76f",  "test": false,  "type": "subscription.billed",  "version": "v2"}
The contract's example (webhooks.md § Examples)
  1. data.bill.periodEnd: Access runs until then.
  2. data.bill.subscriptionUuid: The subscription's sub@…: not a refundable payment.
  3. data.bill.uuid: The bill's sub-hist@…: refund this one, as txUuid.