Files
fluxer/fluxer_docs/src/content/docs/http-api/billing.mdx
T

414 lines
24 KiB
Plaintext

---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Billing
description: Checkout, gift purchase, age verification, refunds, and the Stripe webhook.
---
import RouteHeader from '@/components/RouteHeader.astro';
These routes take payment for premium, verify an account holder's age, and refund the most recent purchase. Checkout and age verification each finish on the payment provider's own pages, so those routes create the provider session and return its URL for the browser to open. [Receive Stripe webhook](#receive-stripe-webhook) takes the signed events the provider sends back. The [Premium resource](/http-api/premium/) owns entitlement state, mirrored billing data and subscription self-service.
A self-hosted deployment never serves age verification or the refund routes, serves the checkout routes only while billing is active, and serves [receive Stripe webhook](#receive-stripe-webhook) while Stripe is serviceable, as [deployment availability](/http-api/deployment-availability/) describes. All of them are user-only except [receive Stripe webhook](#receive-stripe-webhook).
:::note[Every response is a Fluxer object]
The responses are Fluxer redirect, eligibility, refund and acknowledgement objects. They copy only a few payment provider values, such as the identifiers on a [refund](#refund-object) and the `invoice_id` of a [refund eligibility](#refund-eligibility-object) object.
:::
## Redirect URL object
One absolute URL that completes a billing operation in a browser. [Create subscription checkout](#create-subscription-checkout), [create gift checkout](#create-gift-checkout) and [create age verification session](#create-age-verification-session) all answer with it. The object has no session identifier, so a client sends the browser to the returned value.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| url<sup>1</sup> | string | Absolute URL that completes the operation in a browser |
<sup>1</sup> Usually an externally hosted payment provider session URL. [Create subscription checkout](#create-subscription-checkout) can answer with the Fluxer premium callback URL instead
### Example
```json
{
"url": "https://checkout.example.com/c/pay/cs_test_9f8e7d6c5b4a"
}
```
## Refund eligibility object
Whether the account's most recent purchase can still be refunded without operator involvement. When no refundable purchase was resolved, `eligible` and `cancels_subscription` are false. `invoice_id`, `invoice_amount_paid_cents`, `currency`, `paid_at` and `refund_window_expires_at` are null. `cooldown_expires_at` still reports an active cooldown unless the reason is `feature_unavailable`. A client reads `eligible` and `reason` before rendering any amount or timestamp.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| eligible | boolean | Whether the latest purchase can be refunded through [refund latest purchase](#refund-latest-purchase) |
| reason | ?string | [Refund ineligibility reason](#refund-ineligibility-reasons), or null when the purchase is eligible |
| invoice_id<sup>1</sup> | ?string | Payment provider invoice ID, or null when no refundable purchase was resolved |
| invoice_amount_paid_cents<sup>2</sup> | ?integer | Invoice amount paid in the currency minor unit |
| currency<sup>3</sup> | ?string | Lowercase provider currency of the invoice |
| paid_at<sup>4</sup> | ?ISO8601 timestamp | Time the invoice payment settled |
| refund_window_expires_at<sup>5</sup> | ?ISO8601 timestamp | End of the refund window |
| cooldown_expires_at<sup>6</sup> | ?ISO8601 timestamp | End of the account refund cooldown, or null when no cooldown is active |
| cancels_subscription<sup>7</sup> | boolean | Whether refunding the invoice also cancels the subscription that produced it |
<sup>1</sup> The first invoice among the five most recent invoices of the account's payment provider customer that is paid, has a positive amount, and resolves a payment intent or a charge
<sup>2</sup> The field name states cents, but the value is always the minor unit of the invoice currency
<sup>3</sup> Reported as `usd` when the resolved invoice has no currency of its own
<sup>4</sup> Taken from the settlement time of the resolved invoice payment, and from the invoice creation time when that payment has no settlement timestamp
<sup>5</sup> The value of `paid_at` plus exactly three days
<sup>6</sup> The account's first self-service refund time plus exactly 30 days, and null once that instant has passed
<sup>7</sup> True when the resolved invoice was produced by a subscription, which the confirmed refund then cancels immediately
### Example
```json
{
"eligible": true,
"reason": null,
"invoice_id": "in_1QaBcDeFgHiJkLmN",
"invoice_amount_paid_cents": 499,
"currency": "usd",
"paid_at": "2026-08-29T11:04:12.000Z",
"refund_window_expires_at": "2026-09-01T11:04:12.000Z",
"cooldown_expires_at": null,
"cancels_subscription": true
}
```
## Refund ineligibility reasons
| Value | Description |
| --- | --- |
| no_refundable_purchase | The account has no paid invoice with a positive amount and a resolvable payment reference among the invoices considered |
| outside_refund_window | The latest refundable purchase settled more than 3 days ago |
| cooldown_active | The account made a self-service refund less than 30 days ago |
| feature_unavailable | The deployment is self-hosted, or the payment provider is not configured, so self-service refunds are not offered |
## Refund object
The refund that [refund latest purchase](#refund-latest-purchase) created against the resolved invoice. It describes the request the provider accepted, so `status` decides whether money has moved.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| invoice_id | string | Payment provider invoice ID that was refunded |
| payment_intent_id | ?string | Payment provider payment intent ID, or null when the invoice resolved only a charge |
| charge_id | ?string | Payment provider charge ID, or null when the invoice resolved only a payment intent |
| refund_id | ?string | Payment provider refund ID of the created refund |
| refunded_amount_cents<sup>1</sup> | integer | Amount refunded so far in the invoice currency minor unit |
| invoice_amount_paid_cents | integer | Amount paid on the refunded invoice in the invoice currency minor unit |
| currency<sup>2</sup> | string | Lowercase provider currency of the invoice |
| subscription_id<sup>3</sup> | ?string | Payment provider subscription that was cancelled along with the refund |
| status<sup>4</sup> | ?string | Provider status of the refund |
<sup>1</sup> Zero until the provider confirms the refund succeeded. Once confirmed it is the amount the provider accepted, which is one minor unit short of `invoice_amount_paid_cents` when the charge was paid with Pix and the invoice amount is greater than one minor unit
<sup>2</sup> Reported as `usd` when the resolved invoice has no currency of its own
<sup>3</sup> Null while the refund is still pending, and null for a one-off purchase
<sup>4</sup> The provider value, such as `pending`, `succeeded` or `failed`. Money moves only once it is `succeeded`
### Example
```json
{
"invoice_id": "in_1QaBcDeFgHiJkLmN",
"payment_intent_id": "pi_3QaBcDeFgHiJkLmN",
"charge_id": null,
"refund_id": "re_3QaBcDeFgHiJkLmN",
"refunded_amount_cents": 499,
"invoice_amount_paid_cents": 499,
"currency": "usd",
"subscription_id": "sub_1QaBcDeFgHiJkLmN",
"status": "succeeded"
}
```
## Webhook received object
The acknowledgement [receive Stripe webhook](#receive-stripe-webhook) returns once it authenticates the event and queues it for asynchronous processing.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| received | boolean | Always true after the event is authenticated and accepted for processing |
### Example
```json
{
"received": true
}
```
## Create subscription checkout
<RouteHeader method="POST" path="/v1/stripe/checkout/subscription" />
Creates a recurring premium checkout session and returns a [redirect URL](#redirect-url-object) object.
### Limitations
- An unknown price returns 400 `STRIPE_INVALID_PRODUCT`.
- A gift price, a price outside the resolved country catalogue, and an omitted country code for a price that requires one all return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
- An unclaimed account returns 400 `UNCLAIMED_ACCOUNT_CANNOT_MAKE_PURCHASES`.
- An unverified email address returns 403 `PURCHASE_EMAIL_VERIFICATION_REQUIRED`.
- The purchase-disabled premium flag returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `purchase_disabled`.
- A lifetime Visionary account cannot buy a recurring subscription and returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime`.
- An account with an active [in-app purchase](/http-api/in-app-purchases/) subscription returns the same code with the reason `existing_subscription` and the store in the top-level `provider` member. Fluxer never converts this refusal.
- An account whose payment provider customer already holds a subscription in the `active`, `trialing`, `past_due`, `unpaid`, `incomplete` or `paused` state returns the same code with the reason `existing_subscription`, unless Fluxer converts the purchase into a scheduled billing cycle change, as described below.
`reason` is a top-level member of the error response, and an `existing_subscription` refusal also has the blocking status in the top-level `subscription_status` member.
Fluxer evaluates the lifetime refusal before the unclaimed account, unverified email and purchase flag refusals, so a lifetime Visionary account submitting a recurring price always receives the reason `lifetime` whatever its account state.
An account already holding an `active` or `trialing` subscription can submit a registered recurring non-gift price for the other billing cycle. Fluxer then schedules a billing cycle change to the submitted cycle at the end of the current period. That is exactly what [change subscription billing cycle](/http-api/premium/#change-subscription-billing-cycle) with `effective_at` set to `period_end` does. The response is 200 with `url` set to the Fluxer premium callback URL.
The conversion requires a stored billing cycle on the account that differs from the submitted price's cycle. Any other blocking subscription status, no stored billing cycle, and a matching cycle all still produce 403 `PREMIUM_PURCHASE_BLOCKED`. When scheduling the change fails, the caller receives that failure.
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| price_id | string | Registered recurring price ID (1-256 characters) |
| country_code?<sup>1</sup> | string | Two-letter country used to select the regional price catalogue (2 characters) |
| client_geoip_country_code?<sup>2</sup> | string | Two-letter country the client previously observed for itself (2 characters) |
| eu_withdrawal_waiver_accepted?<sup>3</sup> | boolean | Whether the digital content withdrawal waiver was explicitly accepted |
| payment_method?<sup>4</sup> | string | Either `card`, `pix` or `upi` (default `card`) |
| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
<sup>1</sup> Read only when Fluxer cannot geolocate the request, because the request-time geolocation country wins. The resolved country is required whenever the price is not in USD or EUR, and the price is validated against the catalogue for that country, so a client cannot buy outside the catalogue of the country it is browsing from
<sup>2</sup> Read to resolve the effective country for the withdrawal waiver, and recorded with the payment
<sup>3</sup> Considered only when the effective country is inside the European Union or European Economic Area. The effective country is the request-time geolocation country, then the client-observed country, then the pricing country
<sup>4</sup> `pix` requires a BRL recurring price and `upi` requires an INR recurring price. Both are rejected for a gift price or a non-recurring price with 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`. A recurring BRL price from the Fluxer price catalogue always checks out with `pix`, whatever this field says. A catalogue the operator manages has no such rule
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [redirect URL](#redirect-url-object) object | The checkout session was created, or a blocked purchase was converted into a scheduled billing cycle change |
| 400<sup>5</sup> | [error response](/http-api/#error-response) | The price, the account, or the payment provider blocks the checkout |
| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
<sup>5</sup> The price is unknown or not valid for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
### Side effects
Checkout collects the required consent, tax information and any promotion code on the provider's pages.
A converted purchase schedules a billing cycle change instead of opening checkout. It clears pending cancellation and sends [User Update](/gateway/events/#user-update) to every account session.
Premium entitlement otherwise changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook).
### Rate limit
3 requests per minute for each authenticated user, on the `stripe:checkout:subscription` bucket.
## Create gift checkout
<RouteHeader method="POST" path="/v1/stripe/checkout/gift" />
Creates a one-off premium gift checkout session and returns a [redirect URL](#redirect-url-object) object.
### Limitations
- The claimed account, verified email and purchase flag requirements of [create subscription checkout](#create-subscription-checkout) apply unchanged, with the same codes.
- The submitted price is a registered gift price for the resolved country catalogue, so a recurring price returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
Neither the lifetime block nor the existing subscription block applies here, so a lifetime Visionary account can buy gifts.
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| price_id | string | Registered gift price ID (1-256 characters) |
| country_code?<sup>1</sup> | string | Two-letter country used to select the regional gift price catalogue (2 characters) |
| client_geoip_country_code? | string | Two-letter country the client previously observed for itself (2 characters) |
| eu_withdrawal_waiver_accepted? | boolean | Whether the digital content withdrawal waiver was explicitly accepted |
| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
| payment_method? | string | Either `card`, `pix` or `upi`, which this operation ignores |
<sup>1</sup> Read only when Fluxer cannot geolocate the request, because the request-time geolocation country wins. The resolved country is required whenever the gift price is not in USD or EUR
A submitted `pix` or `upi` is validated against the enum and then discarded. The session restricts no payment method type, so the provider offers whatever the deployment's provider account accepts for the resolved currency.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [redirect URL](#redirect-url-object) object | The checkout session was created |
| 400<sup>2</sup> | [error response](/http-api/#error-response) | The price, the account, or the payment provider blocks the checkout |
| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
<sup>2</sup> The price is unknown or is not a gift price for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
### Side effects
The returned URL opens a one-off checkout. The [gift](/http-api/gifts/#gift-object) appears in [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) only after payment is confirmed.
### Rate limit
3 requests per minute for each authenticated user, on the `stripe:checkout:gift` bucket.
## Create age verification session
<RouteHeader method="POST" path="/v1/users/@me/age-verification" />
Creates a setup mode session that verifies the account holder controls a credit card, and returns a [redirect URL](#redirect-url-object) object. It takes no request body.
### Limitations
- A deployment with no configured provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` before any other check.
- An account that already has the age-verified adult flag then returns 400 `AGE_VERIFICATION_ALREADY_VERIFIED`.
:::note[Only a credit card satisfies verification]
The session requests 3-D Secure authentication on the card and takes no payment. Verification succeeds only when the resulting payment method reports credit funding, and a debit or prepaid card leaves the account unverified without an error here.
:::
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [redirect URL](#redirect-url-object) object | The verification session was created |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The account is already age verified, or the provider is not configured or rejected the request |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
<sup>1</sup> A rejected request returns `STRIPE_ERROR`
### Side effects
Adult age verification is granted only after [receive Stripe webhook](#receive-stripe-webhook) processes the matching completion event and confirms a credit card. The grant sets the age-verified adult flag and sends [User Update](/gateway/events/#user-update) to every session owned by the account.
### Rate limit
3 requests per minute for each authenticated user, on the `age_verification` bucket.
## Get refund eligibility
<RouteHeader method="GET" path="/v1/premium/refund-eligibility" />
Returns the [refund eligibility](#refund-eligibility-object) object for the authenticated account.
A deployment with no configured payment provider answers with `eligible` false and the reason `feature_unavailable`. Otherwise the operation lists the five most recent invoices for the account's payment provider customer and selects the first that is paid, has a positive amount, and resolves a payment intent or a charge.
A failed payment-history lookup or an account with no payment history reports no refundable purchase.
:::note[The same object appears inside premium state]
[Get premium state](/http-api/premium/#get-premium-state) also returns `billing.refund_eligibility`, which Fluxer computes from the invoices it has stored. This endpoint reads the invoices from the payment provider, so the premium state value can be older.
:::
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [refund eligibility](#refund-eligibility-object) object | The eligibility state was returned |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
### Rate limit
30 requests per minute for each authenticated user, on the `stripe:refund:eligibility` bucket.
## Refund latest purchase
<RouteHeader method="POST" path="/v1/premium/refund-latest" />
Refunds the account's most recent eligible purchase and returns the [refund](#refund-object) object.
### Limitations
- A deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE`.
- An account with no selectable invoice returns 400 `STRIPE_NO_PURCHASE_HISTORY`.
- A purchase that settled more than 3 days ago returns 403 `STRIPE_REFUND_OUTSIDE_WINDOW`.
- An account whose 30-day cooldown is still running returns 403 `STRIPE_REFUND_COOLDOWN_ACTIVE`.
The same invoice selection as [get refund eligibility](#get-refund-eligibility) applies.
Repeating the request returns the same provider refund. After a refund that the provider reports as failed or cancelled, the account can attempt the refund again.
:::note[A Pix refund is short one minor unit]
When the resolved charge was paid with Pix and the invoice amount is greater than one minor unit, the requested refund is the invoice amount minus one minor unit.
:::
:::danger[A confirmed refund is irreversible]
Once the provider confirms the refund succeeded, the subscription that produced the invoice is cancelled immediately with no proration and no final invoice, and the 30-day cooldown begins. Neither effect can be undone through this API.
:::
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [refund](#refund-object) object | The refund was created, or the request repeats an earlier refund of this invoice and returns that refund |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The account has no refundable purchase, or the payment provider cannot refund it |
| 403 | [error response](/http-api/#error-response) | The purchase is outside the refund window, or the refund cooldown is active |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
<sup>1</sup> The payment provider is not configured, or the payment method could not be determined, or the provider rejected the refund. The last two return `STRIPE_ERROR`
### Side effects
The operation creates a refund for the invoice amount paid, reduced by one minor unit in the Pix case, with the reason `requested_by_customer`.
A refund the provider confirms as succeeded in the same call cancels the subscription that produced the invoice and starts the 30-day cooldown from that moment. A refund still reported as pending or as awaiting further action does neither. [Receive Stripe webhook](#receive-stripe-webhook) cancels the subscription and starts the cooldown when it processes the provider event that confirms the refund.
A confirmed refund that cancels the current subscription updates the account's premium state and sends [User Update](/gateway/events/#user-update) to every account session.
### Rate limit
3 requests per minute for each authenticated user, on the `stripe:refund:latest` bucket.
## Receive Stripe webhook
<RouteHeader method="POST" path="/v1/stripe/webhook" unauthenticated />
Accepts a signed payment provider event for asynchronous processing and returns a [webhook received](#webhook-received-object) object. The signature header is the credential.
### Limitations
- A missing signature header returns 400 `STRIPE_WEBHOOK_SIGNATURE_MISSING`.
- A deployment with no payment provider client or webhook secret configured then returns 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`.
- A signature header that does not verify returns 401 `STRIPE_WEBHOOK_SIGNATURE_INVALID`.
:::caution[The signature covers the exact bytes]
A proxy that re-encodes, reorders or pretty-prints the payload invalidates the signature.
:::
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Stripe-Signature | string | Payment provider signature computed over the exact raw request body |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [webhook received](#webhook-received-object) object | The event was authenticated and queued |
| 400 | [error response](/http-api/#error-response) | The signature header is missing, or webhook processing is not configured for this deployment |
| 401 | [error response](/http-api/#error-response) | The signature did not verify against the raw body |
| 500 | [error response](/http-api/#error-response) | The event could not be queued |
### Side effects
The response comes before processing, and a redelivered event is applied once.
Accepted events update payment, subscription, gift, donation and age-verification results. Resulting account changes reach connected sessions as [User Update](/gateway/events/#user-update).
### Rate limit
300 requests per minute for each client IP address, or for the authenticated user when a credential happens to be supplied, on the `stripe:webhook` bucket. The bucket is exempt from the global bucket.