In-app purchases
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 for renewals, expiries and refunds, so a purchase stays current without the app.
A subscription purchase grants the same premium as a Stripe subscription. A gift purchase mints one gift code. 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 describes.
| Object | Purpose |
|---|---|
| In-app purchase context | The account token and the products the apps may sell |
| Store purchase | One App Store or Google Play purchase linked to the account |
| Store purchase claim | The result of a claim |
Store providers
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “In-app purchase context object”What an app needs before it shows a paywall. Get in-app purchase context returns it.
Structure
Section titled “Structure”| Field | Type | Description |
|---|---|---|
| app_account_token1 | string | The account token, a lowercase UUID |
| app_store | App Store settings object | App Store purchase settings |
| google_play | Google Play settings object | Google Play purchase settings |
| purchase_blocked_reason2 | ?string | Purchase blocked reason, or null |
| blocking_provider3 | ?string | Provider of the blocking subscription, or null |
1 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
2 Null when the account can buy a subscription. Gift products stay on sale whatever the value is
3 Either stripe, app_store or google_play. It has a value only when the reason is existing_subscription
Example
Section titled “Example”{ "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
Section titled “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
Section titled “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 object] | Products on sale |
App Store product object
Section titled “App Store product object”Structure
Section titled “Structure”| Field | Type | Description |
|---|---|---|
| product_id | string | App Store product ID |
| slot | string | Store slot |
Google Play settings object
Section titled “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
Section titled “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 object] | Products on sale |
Google Play product object
Section titled “Google Play product object”A subscription product has one entry for each base plan on sale.
Structure
Section titled “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 purchase object
Section titled “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
Section titled “Structure”| Field | Type | Description |
|---|---|---|
| id | snowflake | The ID of the store purchase |
| provider | string | Store provider |
| kind | string | Either subscription or gift |
| slot | string | Store slot |
| product_id | string | Store product ID |
| environment1 | string | Either production or sandbox |
| state | string | Store purchase state |
| entitled2 | boolean | Whether the purchase grants premium now |
| expires_at | ?ISO8601 timestamp | End of the paid period, or null for a gift |
| entitled_until3 | ?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 |
1 sandbox is an App Store sandbox purchase or a Google Play license test purchase
2 A sandbox purchase is false unless the account is allowed test purchases on the deployment
3 The grace period end while state is grace, otherwise expires_at. Null for a gift and for a purchase that grants nothing
Example
Section titled “Example”{ "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
Section titled “Store purchase claim object”Structure
Section titled “Structure”| Field | Type | Description |
|---|---|---|
| purchase | store purchase object | The claimed purchase |
| gift_code1 | ?string | Gift code minted by a gift purchase, or null |
1 The same value as purchase.gift_code. Null for a subscription and for a gift the store has not finished charging
Example
Section titled “Example”{ "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
Section titled “Get in-app purchase context”GET/v1/premium/storeReturns the in-app purchase context 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
Section titled “Response”| Status | Body | Condition |
|---|---|---|
| 200 | in-app purchase context object | The context was returned |
Side effects
Section titled “Side effects”The first read creates the account token.
Rate limit
Section titled “Rate limit”30 requests per 10 seconds for each authenticated user, on the store:context bucket.
Claim App Store transaction
Section titled “Claim App Store transaction”POST/v1/premium/store/app-store/transactionsVerifies a StoreKit 2 signed transaction, links the purchase to the authenticated account, applies it, and returns a store purchase claim 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
Section titled “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_BLOCKEDwith the reasonlifetimeand the store in the top-levelprovidermember. - 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
Section titled “JSON body”| Field | Type | Description |
|---|---|---|
| signed_transaction | string | The signed transaction from StoreKit 2 (1-32768 characters) |
Response
Section titled “Response”| Status | Body | Condition |
|---|---|---|
| 200 | store purchase claim object | The purchase was verified and linked |
| 400 | error response | The transaction was not accepted |
| 403 | error response | The purchase belongs to another account, or it cannot grant premium to this account |
| 503 | error response | The App Store is unavailable |
Side effects
Section titled “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 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
Section titled “Rate limit”20 requests per minute for each authenticated user, on the store:claim:app_store bucket.
Claim Google Play purchase
Section titled “Claim Google Play purchase”POST/v1/premium/store/google-play/purchasesVerifies a Google Play purchase token, links the purchase to the authenticated account, applies it, and returns a store purchase claim 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
Section titled “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 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
Section titled “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?1 | string | Package name of the app that made the purchase (1-256 characters) |
1 One of google_play.package_names from the in-app purchase context. Defaults to the first accepted package
Response
Section titled “Response”| Status | Body | Condition |
|---|---|---|
| 200 | store purchase claim object | The purchase was verified and linked |
| 400 | error response | The purchase was not accepted |
| 403 | error response | The purchase belongs to another account, or it cannot grant premium to this account |
| 503 | error response | Google Play is unavailable |
Side effects
Section titled “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 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
Section titled “Rate limit”20 requests per minute for each authenticated user, on the store:claim:google_play bucket.
List in-app purchases
Section titled “List in-app purchases”GET/v1/premium/store/purchasesReturns an array of the store purchase objects linked to the authenticated account, newest first.
Response
Section titled “Response”| Status | Body | Condition |
|---|---|---|
| 200 | array[store purchase object] | The purchases were returned |
Rate limit
Section titled “Rate limit”30 requests per 10 seconds for each authenticated user, on the store:purchases:list bucket.
Release in-app subscription
Section titled “Release in-app subscription”DELETE/v1/premium/store/purchases/{purchase_id}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
Section titled “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
Section titled “Path parameters”| Field | Type | Description |
|---|---|---|
| purchase_id | snowflake | The ID of the store purchase |
Request headers
Section titled “Request headers”| Field | Type | Description |
|---|---|---|
| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof |
JSON body
Section titled “JSON body”The body is the 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 object | Assertion when the method is webauthn |
| webauthn_challenge? | string | Challenge bound to the WebAuthn assertion |
Response
Section titled “Response”| Status | Body | Condition |
|---|---|---|
| 204 | empty | The subscription was released |
| 400 | error response | The purchase is a gift, or the sudo proof was rejected |
| 403 | error response | Sudo mode is required and returns SUDO_MODE_REQUIRED |
| 404 | error response | The purchase is not linked to the account |
Side effects
Section titled “Side effects”The account stops receiving premium from the subscription, and a changed premium state sends User Update to every account session.
Rate limit
Section titled “Rate limit”5 requests per minute for each authenticated user, on the store:purchases:release bucket.
Store notification webhooks
Section titled “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
Section titled “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
Section titled “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.