feat(premium): add App Store and Google Play purchases (#3052)

This commit is contained in:
Hampus
2026-09-30 01:55:19 +02:00
committed by GitHub
parent 0b3418dcbe
commit e8cb167dbf
202 changed files with 17095 additions and 49 deletions
+7 -1
View File
@@ -237,7 +237,13 @@ export default defineConfig({
},
{
label: 'Commerce',
items: ['http-api/billing', 'http-api/premium', 'http-api/gifts', 'http-api/donations'],
items: [
'http-api/billing',
'http-api/premium',
'http-api/in-app-purchases',
'http-api/gifts',
'http-api/donations',
],
},
{
label: 'Client surfaces',
+17 -1
View File
@@ -145,6 +145,22 @@ const OUT_OF_BAND_CREDENTIAL = new Map<string, OutOfBandRoute>([
documentedIn: {file: 'operator/configuration.mdx', anchor: 'POST /webhooks/sweego'},
},
],
[
'POST /webhooks/app-store',
{
reason:
'an App Store Server Notification whose signedPayload must verify against the pinned Apple root before it is queued. The route is hosted-only',
documentedIn: {file: 'http-api/in-app-purchases.mdx', anchor: 'POST /webhooks/app-store'},
},
],
[
'POST /webhooks/google-play',
{
reason:
'a Pub/Sub push whose Google-signed OIDC token must match the configured audience and service account before it is queued. The route is hosted-only',
documentedIn: {file: 'http-api/in-app-purchases.mdx', anchor: 'POST /webhooks/google-play'},
},
],
[
'GET /connections/bluesky/callback',
{
@@ -207,7 +223,7 @@ const EXEMPTION_RULES: ReadonlyArray<ExemptionRule> = [
{
name: 'out-of-band credential',
justification:
'no ordinary client holds the credential. Each entry states its guard, and three are covered in prose',
'no ordinary client holds the credential. Each entry states its guard, and five are covered in prose',
anchors: [{file: 'fluxer_api/src/api/app/ControllerRegistry.ts', anchor: 'installSmsWebhookForwarder(routes'}],
covers: (shape) => OUT_OF_BAND_CREDENTIAL.has(shape),
},
@@ -295,6 +295,7 @@ An entry with one of these actions has `access` set to `read`.
| list_user_guilds | The guild memberships of an account were read |
| list_user_relationships | The relationships of an account were read |
| list_user_sessions | An account's login sessions were read |
| list_user_store_purchases | The in-app purchases of an account were read |
| list_voice_regions | Voice regions were listed |
| list_voice_servers | The voice servers of a region were listed |
| list_webauthn_credentials | An account's WebAuthn credentials were read |
@@ -361,6 +362,7 @@ An entry with any other action has `access` set to `write`.
| queue_bulk_job | A bulk job was queued |
| queue_message_shred | A message shred job was queued |
| queue_refresh_index | A search index rebuild was requested |
| refresh_store_purchases | The in-app purchases of an account were read again from their stores |
| reject_discovery_application | A discovery application was rejected |
| reject_registration | A pending registration was rejected |
| reload_guild | One guild was reloaded on the main Gateway |
@@ -598,7 +600,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
| user:update:bot_status | Updates bot and system account state |
| user:update:dob | Updates dates of birth |
| user:update:email | Updates the email address, marks the address verified, resends verification, and sends a password reset |
| user:update:flags | Updates account flags and premium flags, and ends every login session of an account |
| user:update:flags | Updates account flags and premium flags, refreshes in-app purchases, and ends every login session of an account |
| user:update:mfa | Reads and removes a user's WebAuthn credentials, and removes MFA state |
| user:update:phone | Updates verified phone state |
| user:update:profile | Clears profile fields |
@@ -434,6 +434,88 @@ One recorded change to the account's email address, phone verification state, or
}
```
## Admin store purchase object
One [in-app purchase](/http-api/in-app-purchases/) with the store state behind it. It has more fields than the public [store purchase](/http-api/in-app-purchases/#store-purchase-object) object, and still never exposes a purchase token or the account token.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the store purchase |
| user_id | ?snowflake | The ID of the linked account, or null when unlinked |
| provider | string | Either `app_store` or `google_play` |
| kind | string | Either `subscription` or `gift` |
| slot | string | [Store slot](/http-api/in-app-purchases/#store-slots) |
| environment | string | Either `production` or `sandbox` |
| app_id | string | Bundle ID or package name of the purchasing app |
| product_id | string | Store product ID |
| base_plan_id | ?string | Google Play base plan ID, or null |
| latest_transaction_id<sup>1</sup> | ?string | Latest store transaction or order ID |
| ownership_type<sup>2</sup> | ?string | Store ownership type, or null |
| state | string | [Store purchase state](/http-api/in-app-purchases/#store-purchase-states) |
| store_state<sup>3</sup> | ?string | Raw state the store reported, or null |
| entitled | boolean | Whether the purchase grants premium now |
| expires_at | ?ISO8601 timestamp | End of the paid period |
| grace_ends_at | ?ISO8601 timestamp | End of the store grace period |
| auto_renew | ?boolean | Whether the subscription renews |
| started_at | ?ISO8601 timestamp | The time the subscription or purchase started |
| purchased_at | ?ISO8601 timestamp | The time of the latest payment |
| revoked_at | ?ISO8601 timestamp | The time the store refunded or revoked it |
| revocation_reason | ?string | Reason recorded for the revocation, or null |
| superseded | boolean | Whether a newer Google Play purchase replaced it |
| acknowledged<sup>4</sup> | boolean | Whether Fluxer acknowledged it with the store |
| gift_code | ?string | Gift code minted by a gift purchase, or null |
| bound_at | ?ISO8601 timestamp | The time the purchase was linked to its account |
| last_event_at | ?ISO8601 timestamp | Time of the latest store event applied |
| synced_at | ?ISO8601 timestamp | The time Fluxer last read it from the store |
| created_at | ISO8601 timestamp | The time Fluxer first saw the purchase |
| updated_at | ISO8601 timestamp | The time the record last changed |
<sup>1</sup> The App Store transaction ID, or the Google Play order ID
<sup>2</sup> The App Store value, such as `PURCHASED` or `FAMILY_SHARED`. Null for Google Play
<sup>3</sup> The App Store subscription status number as a string, or the Google Play state name. Null for an App Store gift that was never revoked
<sup>4</sup> Always true for the App Store. For a Google Play gift, true once the purchase was consumed
### Example
```json
{
"id": "1501203318237184000",
"user_id": "1489200013322551296",
"provider": "app_store",
"kind": "subscription",
"slot": "monthly",
"environment": "production",
"app_id": "com.fluxer",
"product_id": "com.fluxer.plutonium.monthly",
"base_plan_id": null,
"latest_transaction_id": "2000000712345678",
"ownership_type": "PURCHASED",
"state": "active",
"store_state": "1",
"entitled": true,
"expires_at": "2026-10-29T09:00:00.000Z",
"grace_ends_at": null,
"auto_renew": true,
"started_at": "2026-09-29T09:00:00.000Z",
"purchased_at": "2026-09-29T09:00:00.000Z",
"revoked_at": null,
"revocation_reason": null,
"superseded": false,
"acknowledged": true,
"gift_code": null,
"bound_at": "2026-09-29T09:00:02.000Z",
"last_event_at": "2026-09-29T09:00:00.000Z",
"synced_at": "2026-09-29T09:00:02.000Z",
"created_at": "2026-09-29T09:00:02.000Z",
"updated_at": "2026-09-29T09:00:02.000Z"
}
```
## Message shred entry object
### Structure
@@ -1181,6 +1263,75 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje
100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
## List user store purchases
<RouteHeader method="GET" path="/v1/admin/users/{user_id}/store-purchases" auditReason />
Lists the [in-app purchases](/http-api/in-app-purchases/) linked to an account, newest first. Requires `user:lookup`. A self-hosted instance never serves this route.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| user_id | snowflake | The ID of the target account |
### Response body
| Field | Type | Description |
| --- | --- | --- |
| purchases | array[[Admin store purchase](#admin-store-purchase-object) object] | The linked purchases |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | The purchases were listed |
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
### Side effects
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_user_store_purchases`, target type `user`, and the metadata key `result_count`.
### Rate limit
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
## Refresh user store purchases
<RouteHeader method="POST" path="/v1/admin/users/{user_id}/store-purchases/refresh" auditReason />
Reads every purchase linked to an account again from its store, applies the result to the account, and returns the updated purchases. Requires `user:update:flags`, the same ACL as [Update user premium flags](#update-user-premium-flags). A self-hosted instance never serves this route.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| user_id | snowflake | The ID of the target account |
### Response body
| Field | Type | Description |
| --- | --- | --- |
| purchases | array[[Admin store purchase](#admin-store-purchase-object) object] | The linked purchases after the refresh |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | The purchases were refreshed |
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
| 503 | [error response](/admin-api/#error-response) | `STORE_BILLING_UNAVAILABLE`, because a store could not be reached |
### Side effects
A subscription the store no longer recognises becomes `expired` and stops granting premium. A changed premium state sends [User Update](/gateway/events/#user-update) to the account's own sessions.
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `refresh_store_purchases`, target type `user`, and the metadata key `purchase_count`.
### Rate limit
100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
## Update user phone verification
<RouteHeader method="PUT" path="/v1/admin/users/{user_id}/phone-verification" auditReason />
@@ -214,6 +214,7 @@ Creates a recurring premium checkout session and returns a [redirect URL](#redir
- 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.
@@ -78,6 +78,11 @@ When billing is switched off or no Stripe secret key is configured on a hosted d
| POST | /v1/premium/refund-latest | [Refund latest purchase](/http-api/billing/#refund-latest-purchase) |
| POST | /v1/premium/switch-to-list-price | [Switch subscription to the list price](/http-api/premium/#switch-subscription-to-the-list-price) |
| POST | /v1/premium/visionary/rejoin | [Rejoin Visionary guild](/http-api/premium/#rejoin-visionary-guild) |
| GET | /v1/premium/store | [Get in-app purchase context](/http-api/in-app-purchases/#get-in-app-purchase-context) |
| POST | /v1/premium/store/app-store/transactions | [Claim App Store transaction](/http-api/in-app-purchases/#claim-app-store-transaction) |
| POST | /v1/premium/store/google-play/purchases | [Claim Google Play purchase](/http-api/in-app-purchases/#claim-google-play-purchase) |
| GET | /v1/premium/store/purchases | [List in-app purchases](/http-api/in-app-purchases/#list-in-app-purchases) |
| DELETE | /v1/premium/store/purchases/{purchase_id} | [Release in-app subscription](/http-api/in-app-purchases/#release-in-app-subscription) |
<sup>1</sup> These are the only `/users/@me` routes a self-hosted deployment may not serve
@@ -87,6 +92,8 @@ When billing is switched off or no Stripe secret key is configured on a hosted d
<sup>4</sup> The same object appears as `billing.refund_eligibility` on [Get premium state](/http-api/premium/#get-premium-state), which every deployment serves, and there a self-hosted deployment reports `eligible` false with the reason `feature_unavailable`
The two [store notification webhooks](/http-api/in-app-purchases/#store-notification-webhooks) are never served on a self-hosted deployment either.
## Registered routes that resolve differently
A route every deployment registers can still produce a different answer on a self-hosted instance. Each operation page documents that difference.
@@ -920,6 +920,26 @@ Service unavailable
Invalid request
### `STORE_BILLING_UNAVAILABLE`
In-app purchases are unavailable right now
### `STORE_NOTIFICATION_UNAUTHORIZED`
The notification signature is invalid
### `STORE_PURCHASE_INVALID`
This purchase could not be verified
### `STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT`
This purchase is linked to a different account
### `STORE_PURCHASE_SANDBOX_NOT_ENTITLED`
Test purchases cannot be applied to this account
### `STREAM_KEY_CHANNEL_MISMATCH`
Stream key channel mismatch
@@ -1132,6 +1152,10 @@ Role wasn't found
Unknown sticker
### `UNKNOWN_STORE_PURCHASE`
Unknown store purchase
### `UNKNOWN_SUSPICIOUS_FLAG`
Unknown suspicious flag
@@ -0,0 +1,455 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: In-app purchases
description: App Store and Google Play purchases, the claim routes the mobile apps call, and the store notification webhooks.
---
import RouteHeader from '@/components/RouteHeader.astro';
The mobile apps sell premium through the App Store and Google Play. The store takes the payment, and the app then hands the purchase to Fluxer through a claim route. Fluxer verifies the purchase with the store, links it to the account, and applies it to the account's premium state. The stores also send [notifications](#store-notification-webhooks) for renewals, expiries and refunds, so a purchase stays current without the app.
A subscription purchase grants the same premium as a [Stripe subscription](/http-api/billing/#create-subscription-checkout). A gift purchase mints one [gift](/http-api/gifts/#gift-object) code. [Get premium state](/http-api/premium/#get-premium-state) reports the active store subscription in `store`, and names the platform that owns the account's recurring subscription in `subscription_provider`. A client manages the subscription only on that platform, so an app does not offer its own management for a `stripe` subscriber.
Every route on this page except the store notification webhooks is user-only. A self-hosted deployment never serves any of them, as [deployment availability](/http-api/deployment-availability/) describes.
| Object | Purpose |
| --- | --- |
| [In-app purchase context](#in-app-purchase-context-object) | The account token and the products the apps may sell |
| [Store purchase](#store-purchase-object) | One App Store or Google Play purchase linked to the account |
| [Store purchase claim](#store-purchase-claim-object) | The result of a claim |
## Store providers
| Value | Description |
| --- | --- |
| app_store | Apple App Store, with StoreKit 2 in the app |
| google_play | Google Play, with Play Billing in the app |
## Store slots
Each configured store product sells one Fluxer product, called its slot.
| Value | Description |
| --- | --- |
| monthly | Premium subscription billed every month |
| yearly | Premium subscription billed every year |
| gift_1_month | One gift code for one month of premium |
| gift_1_year | One gift code for one year of premium |
## Store purchase states
| Value | Description |
| --- | --- |
| pending | The store has not taken the payment yet |
| active | The subscription is paid and renews |
| grace | The renewal payment failed and the store grace period is running |
| billing_retry | The renewal payment failed and the store is retrying without grace |
| on_hold | Google Play has suspended the subscription after a failed payment |
| paused | The subscriber paused the subscription in Google Play |
| canceled | The subscription is paid until `expires_at` and will not renew |
| expired | The subscription has ended |
| revoked | The store refunded or revoked the subscription |
| superseded | A newer Google Play purchase replaced this one |
| purchased | The gift is paid and its code is not minted yet |
| fulfilled | The gift code is minted |
| refunded | The store refunded the gift |
Only `active`, `grace` and `canceled` can grant premium.
## Purchase blocked reasons
| Value | Description |
| --- | --- |
| lifetime | The account holds lifetime Visionary premium |
| existing_subscription | The account already has an active subscription |
| purchase_disabled | Purchases are disabled for the account |
## In-app purchase context object
What an app needs before it shows a paywall. [Get in-app purchase context](#get-in-app-purchase-context) returns it.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| app_account_token<sup>1</sup> | string | The account token, a lowercase UUID |
| app_store | [App Store settings](#app-store-settings-object) object | App Store purchase settings |
| google_play | [Google Play settings](#google-play-settings-object) object | Google Play purchase settings |
| purchase_blocked_reason<sup>2</sup> | ?string | [Purchase blocked reason](#purchase-blocked-reasons), or null |
| blocking_provider<sup>3</sup> | ?string | Provider of the blocking subscription, or null |
<sup>1</sup> Stable for the account. An App Store purchase passes it as `appAccountToken`, and a Google Play purchase passes it as `obfuscatedAccountId`. Fluxer links a purchase that has the token to the account even when the store notification arrives before the claim
<sup>2</sup> Null when the account can buy a subscription. Gift products stay on sale whatever the value is
<sup>3</sup> Either `stripe`, `app_store` or `google_play`. It has a value only when the reason is `existing_subscription`
### Example
```json
{
"app_account_token": "0f7c2a1e-5b3d-4c8e-9a61-2d4f8b7e3c10",
"app_store": {
"enabled": true,
"bundle_ids": ["com.fluxer"],
"products": [
{"product_id": "com.fluxer.plutonium.monthly", "slot": "monthly"},
{"product_id": "com.fluxer.gift.1month", "slot": "gift_1_month"}
]
},
"google_play": {
"enabled": true,
"package_names": ["com.fluxer"],
"products": [
{"product_id": "plutonium", "base_plan_id": "monthly", "slot": "monthly"},
{"product_id": "gift_1_month", "base_plan_id": null, "slot": "gift_1_month"}
]
},
"purchase_blocked_reason": null,
"blocking_provider": null
}
```
## App Store settings object
The App Store purchases the deployment accepts. When the App Store is not set up, `enabled` is false and both arrays are empty.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether App Store purchases are accepted |
| bundle_ids | array[string] | App bundle IDs whose purchases are accepted |
| products | array[[App Store product](#app-store-product-object) object] | Products on sale |
## App Store product object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| product_id | string | App Store product ID |
| slot | string | [Store slot](#store-slots) |
## Google Play settings object
The Google Play purchases the deployment accepts. When Google Play is not set up, `enabled` is false and both arrays are empty.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether Google Play purchases are accepted |
| package_names | array[string] | App package names whose purchases are accepted |
| products | array[[Google Play product](#google-play-product-object) object] | Products on sale |
## Google Play product object
A subscription product has one entry for each base plan on sale.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| product_id | string | Google Play product ID |
| base_plan_id | ?string | Base plan ID, or null for a one-time product |
| slot | string | [Store slot](#store-slots) |
## Store purchase object
One purchase as Fluxer last read it from the store. It never exposes purchase tokens, transaction IDs or the account token.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the store purchase |
| provider | string | [Store provider](#store-providers) |
| kind | string | Either `subscription` or `gift` |
| slot | string | [Store slot](#store-slots) |
| product_id | string | Store product ID |
| environment<sup>1</sup> | string | Either `production` or `sandbox` |
| state | string | [Store purchase state](#store-purchase-states) |
| entitled<sup>2</sup> | boolean | Whether the purchase grants premium now |
| expires_at | ?ISO8601 timestamp | End of the paid period, or null for a gift |
| entitled_until<sup>3</sup> | ?ISO8601 timestamp | End of access from this purchase |
| will_renew | ?boolean | Whether the subscription renews, or null for a gift |
| gift_code | ?string | Gift code minted by a gift purchase, or null |
| created_at | ISO8601 timestamp | The time Fluxer first saw the purchase |
<sup>1</sup> `sandbox` is an App Store sandbox purchase or a Google Play license test purchase
<sup>2</sup> A `sandbox` purchase is false unless the account is allowed test purchases on the deployment
<sup>3</sup> The grace period end while `state` is `grace`, otherwise `expires_at`. Null for a gift and for a purchase that grants nothing
### Example
```json
{
"id": "1501203318237184000",
"provider": "app_store",
"kind": "subscription",
"slot": "monthly",
"product_id": "com.fluxer.plutonium.monthly",
"environment": "production",
"state": "active",
"entitled": true,
"expires_at": "2026-10-29T09:00:00.000Z",
"entitled_until": "2026-10-29T09:00:00.000Z",
"will_renew": true,
"gift_code": null,
"created_at": "2026-09-29T09:00:00.000Z"
}
```
## Store purchase claim object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| purchase | [store purchase](#store-purchase-object) object | The claimed purchase |
| gift_code<sup>1</sup> | ?string | Gift code minted by a gift purchase, or null |
<sup>1</sup> The same value as `purchase.gift_code`. Null for a subscription and for a gift the store has not finished charging
### Example
```json
{
"purchase": {
"id": "1501203318237184001",
"provider": "google_play",
"kind": "gift",
"slot": "gift_1_month",
"product_id": "gift_1_month",
"environment": "production",
"state": "fulfilled",
"entitled": true,
"expires_at": null,
"entitled_until": null,
"will_renew": null,
"gift_code": "q7Xr2mPz9LkT4vBn8cWd3HsYf6JaE1Gu",
"created_at": "2026-09-29T09:00:00.000Z"
},
"gift_code": "q7Xr2mPz9LkT4vBn8cWd3HsYf6JaE1Gu"
}
```
## Get in-app purchase context
<RouteHeader method="GET" path="/v1/premium/store" />
Returns the [in-app purchase context](#in-app-purchase-context-object) object for the authenticated account.
The blocked reason is decided in a fixed order. A lifetime Visionary account reports `lifetime`, then the purchase-disabled premium flag reports `purchase_disabled`, then an active store subscription reports `existing_subscription` with that store, and then an active Stripe subscription reports `existing_subscription` with `stripe`.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [in-app purchase context](#in-app-purchase-context-object) object | The context was returned |
### Side effects
The first read creates the account token.
### Rate limit
30 requests per 10 seconds for each authenticated user, on the `store:context` bucket.
## Claim App Store transaction
<RouteHeader method="POST" path="/v1/premium/store/app-store/transactions" />
Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account, applies it, and returns a [store purchase claim](#store-purchase-claim-object) object. Repeating the claim for the same purchase returns the same result.
The app finishes the transaction after a 200 response or any 400 or 403 error listed under limitations. It leaves the transaction unfinished after a 429, a 503 `STORE_BILLING_UNAVAILABLE`, another 5xx or a network failure. StoreKit then delivers the transaction again, so the claim can be retried later.
### Limitations
- A transaction that fails verification, is for an unknown product or app, or comes from Xcode or local testing returns 400 `STORE_PURCHASE_INVALID`.
- A gift bought in a quantity above one mints no gift code and returns 400 `STORE_PURCHASE_INVALID`.
- A purchase already linked to another live account returns 403 `STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT`. So does a purchase whose account token belongs to another live account.
- A sandbox purchase on an account that is not allowed test purchases returns 403 `STORE_PURCHASE_SANDBOX_NOT_ENTITLED`.
- A subscription claimed by a lifetime Visionary account returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime` and the store in the top-level `provider` member.
- A deployment without the App Store set up, or an App Store that cannot be reached, returns 503 `STORE_BILLING_UNAVAILABLE`.
After a test purchase or lifetime refusal the purchase is still linked to the account. It grants nothing.
A purchase shared through Family Sharing is linked only through this route.
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| signed_transaction | string | The signed transaction from StoreKit 2 (1-32768 characters) |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [store purchase claim](#store-purchase-claim-object) object | The purchase was verified and linked |
| 400 | [error response](/http-api/#error-response) | The transaction was not accepted |
| 403 | [error response](/http-api/#error-response) | The purchase belongs to another account, or it cannot grant premium to this account |
| 503 | [error response](/http-api/#error-response) | The App Store is unavailable |
### Side effects
Fluxer reads the purchase from the App Store. A subscription that grants premium updates the account's premium state, and a gift purchase mints its gift code once. A changed premium state sends [User Update](/gateway/events/#user-update) to every account session.
When the purchase has no account token, or has one that belongs to no live account, Fluxer sets the claiming account's token on it.
### Rate limit
20 requests per minute for each authenticated user, on the `store:claim:app_store` bucket.
## Claim Google Play purchase
<RouteHeader method="POST" path="/v1/premium/store/google-play/purchases" />
Verifies a Google Play purchase token, links the purchase to the authenticated account, applies it, and returns a [store purchase claim](#store-purchase-claim-object) object. Repeating the claim for the same purchase returns the same result.
Fluxer acknowledges the purchase with Google Play. The app does not need to acknowledge it.
### Limitations
- A token that Google Play does not recognise, a product that is not on sale, a product that does not match the token, and a package name that is not accepted all return 400 `STORE_PURCHASE_INVALID`.
- The ownership, test purchase and lifetime refusals of [claim App Store transaction](#claim-app-store-transaction) apply unchanged, with the same codes.
- A deployment without Google Play set up, or a Google Play that cannot be reached, returns 503 `STORE_BILLING_UNAVAILABLE`.
A lifetime Visionary account that buys a subscription through Google Play is refunded. So is a gift bought through Google Play in a quantity above one.
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| purchase_token | string | Purchase token from Play Billing (1-1024 characters) |
| product_id | string | Google Play product ID of the purchase (1-256 characters) |
| package_name?<sup>1</sup> | string | Package name of the app that made the purchase (1-256 characters) |
<sup>1</sup> One of `google_play.package_names` from the [in-app purchase context](#in-app-purchase-context-object). Defaults to the first accepted package
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [store purchase claim](#store-purchase-claim-object) object | The purchase was verified and linked |
| 400 | [error response](/http-api/#error-response) | The purchase was not accepted |
| 403 | [error response](/http-api/#error-response) | The purchase belongs to another account, or it cannot grant premium to this account |
| 503 | [error response](/http-api/#error-response) | Google Play is unavailable |
### Side effects
Fluxer reads the purchase from Google Play. A subscription that grants premium updates the account's premium state, and a gift purchase mints its gift code once and is then consumed. A changed premium state sends [User Update](/gateway/events/#user-update) to every account session.
A purchase that replaces an earlier subscription, such as an upgrade, takes over that subscription's account link. The earlier purchase becomes `superseded`.
### Rate limit
20 requests per minute for each authenticated user, on the `store:claim:google_play` bucket.
## List in-app purchases
<RouteHeader method="GET" path="/v1/premium/store/purchases" />
Returns an array of the [store purchase](#store-purchase-object) objects linked to the authenticated account, newest first.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | array[[store purchase](#store-purchase-object) object] | The purchases were returned |
### Rate limit
30 requests per 10 seconds for each authenticated user, on the `store:purchases:list` bucket.
## Release in-app subscription
<RouteHeader method="DELETE" path="/v1/premium/store/purchases/{purchase_id}" mfa />
Unlinks a store subscription from the authenticated account and returns 204 with an empty body. Requires sudo mode. Another account can then claim the subscription.
Releasing does not cancel the subscription. The subscriber cancels it in the store.
### Limitations
- A purchase that is not linked to the account returns 404 `UNKNOWN_STORE_PURCHASE`.
- A gift purchase cannot be released and returns 400 `STORE_PURCHASE_INVALID`.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| purchase_id | snowflake | The ID of the store purchase |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof |
### JSON body
The body is the [sudo verification fields](/http-api/guilds/#sudo-verification-fields). A request that already has a valid proof can omit it.
| Field | Type | Description |
| --- | --- | --- |
| password? | string | Current account password |
| mfa_method? | string | MFA method, either `totp` or `webauthn` |
| mfa_code? | string | Authenticator code or unconsumed backup code when the method is `totp` (1-32 characters) |
| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | Assertion when the method is `webauthn` |
| webauthn_challenge? | string | Challenge bound to the WebAuthn assertion |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 204 | empty | The subscription was released |
| 400 | [error response](/http-api/#error-response) | The purchase is a gift, or the sudo proof was rejected |
| 403 | [error response](/http-api/#error-response) | Sudo mode is required and returns `SUDO_MODE_REQUIRED` |
| 404 | [error response](/http-api/#error-response) | The purchase is not linked to the account |
### Side effects
The account stops receiving premium from the subscription, and a changed premium state sends [User Update](/gateway/events/#user-update) to every account session.
### Rate limit
5 requests per minute for each authenticated user, on the `store:purchases:release` bucket.
## Store notification webhooks
The stores call these two routes. No client calls them, and they have no OpenAPI entry. Both routes are served only on the hosted deployment, and both answer 503 `STORE_BILLING_UNAVAILABLE` while their store is not set up.
Fluxer reads the purchase from the store again after each notification, and a redelivered notification is applied once. A Google Play voided purchase is the one notification that changes a purchase by itself. It marks a gift `refunded`, or a subscription `revoked` when the voided order is its latest order.
When a gift is refunded, Fluxer revokes its gift code and takes the gift time back from the account that redeemed it. When the store reverses the refund, the gift code and its gift time come back.
Fluxer also reads the App Store notification history and the Google Play voided purchases once a day, so a notification that was missed is still applied.
### App Store Server Notifications
`POST /webhooks/app-store` receives App Store Server Notifications V2. The body is `{"signedPayload": "..."}`, signed by Apple. Fluxer verifies the signature before it answers.
| Status | Condition |
| --- | --- |
| 200 | The notification was verified and queued |
| 401 | The body or its signature was rejected and returns `STORE_NOTIFICATION_UNAUTHORIZED` |
| 503 | The App Store is not set up |
300 requests per minute for each client IP address, on the `store:webhook:app_store` bucket. The bucket is exempt from the global bucket.
### Google Play real-time developer notifications
`POST /webhooks/google-play` receives real-time developer notifications as Pub/Sub push messages. Each message is authenticated by a Google-signed token in the `Authorization` header, whose audience and service account the deployment configures.
| Status | Condition |
| --- | --- |
| 204 | The message was queued, or it was not a Pub/Sub message and was dropped |
| 401 | The token was rejected and returns `STORE_NOTIFICATION_UNAUTHORIZED` |
| 503 | Google Play is not set up |
300 requests per minute for each client IP address, on the `store:webhook:google_play` bucket. The bucket is exempt from the global bucket.
@@ -23,6 +23,7 @@ Every route except [get price IDs](#get-price-ids) is user-only. A [self-hosted
| [Billing invoice](#billing-invoice-object) | One mirrored invoice |
| [Billing payment method](#billing-payment-method-object) | One mirrored payment method |
| [Premium pricing state](#premium-pricing-state-object) | The resolved checkout catalogue |
| [Store subscription](#store-subscription-object) | The active [in-app purchase](/http-api/in-app-purchases/) subscription |
:::note[Manage billing through the customer portal]
[Get premium state](#get-premium-state) returns invoices, payment methods and subscription details. Use [Create customer portal](#create-customer-portal) to open the billing interface.
@@ -211,6 +212,106 @@ A client gates a premium feature on `effective.is_premium`.
| effective | [effective premium state](#effective-premium-state-object) object | The entitlement every premium feature check reads |
| billing | [premium billing state](#premium-billing-state-object) object | The mirrored payment provider data for the account |
| pricing | [premium pricing state](#premium-pricing-state-object) object | The resolved checkout prices for the requested country |
| store<sup>1</sup> | ?[store subscription](#store-subscription-object) object | The active App Store or Google Play subscription, or null |
| subscription_provider<sup>2</sup> | ?string | The billing platform that owns the current recurring subscription, one of `stripe`, `app_store` or `google_play`, or null |
<sup>1</sup> Always null on a self-hosted deployment. When the account has more than one active store subscription, this is the one whose access ends last
<sup>2</sup> Null for a gift, a lifetime Visionary entitlement, or no subscription. `stripe` needs a Stripe subscription with a billing cycle and a paid period that has not ended. An account can hold an active Stripe subscription and an active store subscription at once, and then this names the one paid through later. A self-hosted deployment reports only `stripe` or null. A client sends a `stripe` subscriber to [create customer portal](#create-customer-portal) and a store subscriber to `store.manage_url`
### Example
```json
{
"actual": {
"premium_type": 1,
"premium_since": "2026-09-19T09:00:00.000Z",
"premium_until": "2026-10-19T09:00:00.000Z",
"premium_will_cancel": false,
"premium_billing_cycle": null,
"premium_lifetime_sequence": null,
"premium_grace_ends_at": null,
"has_active_paid_premium": true,
"is_visionary": false,
"has_ever_purchased": true
},
"effective": {
"is_premium": true,
"premium_type": 1,
"premium_since": "2026-09-19T09:00:00.000Z",
"premium_until": "2026-10-19T09:00:00.000Z",
"premium_will_cancel": false,
"premium_billing_cycle": null,
"premium_lifetime_sequence": null,
"premium_grace_ends_at": null,
"premium_enabled_override": false,
"premium_purchase_disabled": false,
"premium_perks_disabled": false,
"self_hosted": false,
"bot": false
},
"billing": {
"stripe_customer_id": null,
"current_subscription_price": null,
"pending_subscription_change": null,
"list_price_switch": {
"available": false,
"reason": "no_active_subscription",
"pending": false,
"current_price_id": null,
"current_amount_minor": null,
"list_price_id": null,
"list_amount_minor": null,
"currency": null,
"billing_cycle": null,
"effective_at": null
},
"subscription": null,
"invoices": [],
"invoices_has_more": false,
"payment_methods": [],
"refund_eligibility": {
"eligible": false,
"reason": "no_refundable_purchase",
"invoice_id": null,
"invoice_amount_paid_cents": null,
"currency": null,
"paid_at": null,
"refund_window_expires_at": null,
"cooldown_expires_at": null,
"cancels_subscription": false
}
},
"pricing": {
"country_code": "US",
"localized": {
"monthly": "price_1QaMonthlyUsd",
"yearly": "price_1QaYearlyUsd",
"gift_1_month": "price_1QaGift1MonthUsd",
"gift_1_year": "price_1QaGift1YearUsd",
"monthly_amount_minor": 499,
"yearly_amount_minor": 4999,
"gift_1_month_amount_minor": 499,
"gift_1_year_amount_minor": 4999,
"currency": "USD",
"gift_currency": "USD"
}
},
"store": {
"provider": "app_store",
"purchase_id": "1501203318237184000",
"slot": "monthly",
"billing_cycle": "monthly",
"state": "active",
"expires_at": "2026-10-19T09:00:00.000Z",
"grace_ends_at": null,
"will_renew": true,
"manage_url": "https://apps.apple.com/account/subscriptions",
"environment": "production"
},
"subscription_provider": "app_store"
}
```
## Actual premium state object
@@ -452,6 +553,48 @@ The checkout catalogue resolved for the request. A client can render prices with
Fluxer reports an unresolvable catalogue as null here, and the request still succeeds. [Get price IDs](#get-price-ids) answers 400 `STRIPE_ERROR` for the same condition.
## Store subscription object
The App Store or Google Play subscription that grants the account premium. The [store purchase](/http-api/in-app-purchases/#store-purchase-object) it comes from is listed by [list in-app purchases](/http-api/in-app-purchases/#list-in-app-purchases).
While no Stripe subscription is active, a store subscription leaves `actual.premium_billing_cycle` null. A client reads the cycle here and sends the subscriber to `manage_url`, since [create customer portal](#create-customer-portal) cannot manage a store subscription.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| provider | string | Either `app_store` or `google_play` |
| purchase_id | snowflake | The ID of the store purchase |
| slot | string | Either `monthly` or `yearly` |
| billing_cycle | string | [Billing cycle](#billing-cycles) |
| state<sup>1</sup> | string | [Store purchase state](/http-api/in-app-purchases/#store-purchase-states) |
| expires_at | ISO8601 timestamp | End of the paid period |
| grace_ends_at | ?ISO8601 timestamp | End of the store grace period, or null outside grace |
| will_renew | boolean | Whether the subscription renews |
| manage_url<sup>2</sup> | string | Store page where the subscriber manages or cancels it |
| environment | string | Either `production` or `sandbox` |
<sup>1</sup> One of `active`, `grace` or `canceled`
<sup>2</sup> The App Store subscriptions page, or the Google Play subscriptions page for the product and package
### Example
```json
{
"provider": "google_play",
"purchase_id": "1501203318237184000",
"slot": "yearly",
"billing_cycle": "yearly",
"state": "active",
"expires_at": "2027-09-29T09:00:00.000Z",
"grace_ends_at": null,
"will_renew": true,
"manage_url": "https://play.google.com/store/account/subscriptions?sku=plutonium&package=com.fluxer",
"environment": "production"
}
```
## Get price IDs
<RouteHeader method="GET" path="/v1/premium/price-ids" unauthenticated />