SubscriptionFlow MOBILE STORES API Contact Support

SubscriptionFlow Mobile Stores API Reference (1.0.0)

Download OpenAPI specification:Download

Developer reference for the App Store / Google Play sync (Mobile Stores): register in-app purchases, issue per-customer account tokens, read cross-channel entitlements and feed Apple's refund-consumption answers. Apple and Google bill these subscribers; SubscriptionFlow mirrors them as customers, subscriptions, paid invoices and payments, and never charges, retries or duns them.

The general SubscriptionFlow REST API is documented separately at subscriptionflow.com/api.

Overview

How the sync works

  1. Connect the stores under Settings > Marketplace > Integrations > Apple App Store / Google Play and map each store product (and Google base plan) to a SubscriptionFlow plan price.
  2. Point the store notifications at SubscriptionFlow. Apple App Store Server Notifications V2 go to https://demo.subscriptionflow.com/webhook/apple; the Google Play RTDN Pub/Sub push subscription goes to https://demo.subscriptionflow.com/webhook/google-play (see Inbound store notifications).
  3. Tag every purchase with an account token. Your backend issues one per customer with POST /mobile-stores/account-tokens and hands it to the app, which sets it as appAccountToken (iOS) or obfuscatedExternalAccountId (Android).
  4. Report purchases. The app reports each purchase with POST /mobile-stores/client/purchases (the Flutter SDK subscriptionflow_iap does this for you); your backend can also register or restore a purchase for a customer with POST /mobile-stores/purchases.
  5. Gate features on entitlements. Read GET /mobile-stores/client/entitlements in the app, or GET /mobile-stores/customers/{customer}/entitlements on your backend. Entitlements combine web, App Store and Google Play subscriptions.

SubscriptionFlow never trusts the request for product or price: every purchase is re-fetched from Apple or Google, and the store's answer decides product, amount, currency, status and expiry.

Two kinds of API

Surface Who calls it Authentication Rate limit
Backend API (/mobile-stores/purchases, /mobile-stores/account-tokens, /mobile-stores/customers/...) Your server OAuth2 client-credentials bearer token with the access-global-api scope Shared REST API budget
App / SDK API (/mobile-stores/client/...) Your mobile app X-SF-Account-Token + X-SF-Client-Secret headers Own per-token and per-account limits
Inbound webhooks (/webhook/apple, /webhook/google-play) Apple / Google Signed JWS body (Apple), OIDC bearer token (Google) None

Requests and responses are JSON (Content-Type: application/json). All endpoints answer JSON, including errors.

Authentication

Backend API: OAuth2 client credentials

Backend endpoints use the same REST API client as the rest of the SubscriptionFlow API. Create an API client (see the Client Credentials Guide) whose token carries the access-global-api scope, then request a token:

curl --location --request POST 'https://demo.subscriptionflow.com/oauth/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=your_client_id' \
--data-urlencode 'client_secret=your_client_secret' \
--data-urlencode 'grant_type=client_credentials'

Send it as Authorization: Bearer <access_token>. A missing, expired or invalid token answers 401 {"message": "Unauthenticated."}; a token without the access-global-api scope answers 403 {"message": "Invalid scope(s) provided."}. MCP agent tokens and customer-portal tokens never hold this scope.

Never ship these credentials in your app.

App / SDK API: account token + client secret

App endpoints take no OAuth token. They authenticate with the pair your backend received from POST /mobile-stores/account-tokens:

X-SF-Account-Token: c92b1774-af5d-4dc4-a375-cf377bfc118a
X-SF-Client-Secret: wjMQ6V90BaMwTVOXL7dYH8AKREmptMpFGE77V1jH5GdBojHt

The token must be active (not revoked) and the secret must match. Anything else, including a missing header, answers 401 {"status": false, "message": "Invalid account token or client secret"}. The customer is always the token's customer: the app can never act for another customer.

Account Tokens

An account token is a SubscriptionFlow-issued UUID that stands for one customer. The app sets it on every store purchase (appAccountToken on iOS, obfuscatedExternalAccountId on Android), so purchases and notifications link to the right customer without exposing the customer ID.

  • Issue with POST /mobile-stores/account-tokens. The first call for a customer answers 201 with account_token and a client_secret. The secret is returned only once (SubscriptionFlow stores a SHA-256 hash of it): keep it on your backend and pass it to the signed-in app.
  • Reuse. Calling again while the customer has an active token answers 200 with the same account_token, client_secret: null and reused: true.
  • Rotate. Send "rotate": true to revoke every active token of the customer and get a new pair (201).
  • Revoke with DELETE /mobile-stores/account-tokens/{accountToken}. A revoked token can no longer authenticate the app, but it still identifies its customer: purchases tagged with it keep linking to that customer and can never be claimed by anyone else. Already linked purchases stay linked.

Require SubscriptionFlow-issued account tokens (a per-store setting, on by default for stores connected from 28 October 2026): when on, only issued tokens link purchases. When off, a raw SubscriptionFlow customer ID set as the account token is also accepted.

Error Handling

Every Mobile Stores endpoint answers JSON. Errors raised by the endpoints themselves have this shape:

{
  "status": false,
  "message": "purchase_token is required for Google Play",
  "errors": {
    "purchase_token": ["purchase_token is required for Google Play"]
  }
}

errors is present only for field validation failures (and for Customer not found on customer_id). message is always set; it is the first validation message when errors is present.

Code Meaning
200 / 201 Success.
401 Missing or invalid bearer token (backend) or token pair (app).
403 Bearer token lacks the access-global-api scope.
404 Unknown customer or account token (path parameter).
409 The purchase belongs to someone else (backend), or any refusal to link (app; see below).
422 Validation failed, store not connected, the store does not know or cannot verify the purchase, environment mismatch (backend), or the purchase needs manual review. Do not retry unchanged.
429 Rate limit hit. Body {"message": "Too Many Attempts."}; wait for Retry-After seconds.
502 Apple / Google could not be reached, or the purchase could not be mirrored. Retry later.
503 The same purchase is being processed by another request or notification. Retry shortly.

The app endpoint never reveals whether a purchase exists. Every ownership refusal, purchase unknown to the store, or production purchase sent to a Sandbox connection answers the same 409 This purchase cannot be linked to this account.

Idempotency. Registering or reporting the same purchase again is safe: SubscriptionFlow re-fetches the store state and re-syncs; each store charge and refund is booked at most once (unique per store transaction). Issuing a token without rotate is idempotent, revoking is idempotent, and usage reports are upserts. Retry 502, 503 and 429; do not retry other 4xx answers unchanged.

Rate Limits

Limits are requests per minute.

Backend API endpoints share your account's REST API budget (sf_rest_ratelimit: 500 requests per minute per account) with the rest of the SubscriptionFlow REST API.

App / SDK API endpoints have their own limiter and never spend the REST budget:

Bucket Limit Keyed by
Entitlement reads (GET /mobile-stores/client/entitlements) 60 account token
Entitlement reads 3000 account
Purchase reports (POST /mobile-stores/client/purchases) 30 account token
Purchase reports 1000 account
Requests whose token pair does not authenticate 30 account + client IP address

Authenticated limits are per token, not per IP, so many devices behind one NAT are fine. Requests with a wrong token or secret only count against the per-IP bucket, so junk traffic cannot use up your account's app budget. Contact support to raise a limit.

A limited request answers 429 {"message": "Too Many Attempts."} with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining headers.

Sandbox and App Review

Each store connection has an Environment: Production or Sandbox. A purchase is mirrored only when its environment matches the connection, so TestFlight, App Review and Google licence-tester purchases never become revenue in a Production account.

App endpoint on a Production connection. Apple's reviewers and TestFlight users buy with sandbox accounts on your live app, and Google licence testers make test purchases. For such a purchase POST /mobile-stores/client/purchases verifies it (an Apple purchase unknown to Production is looked up in Apple's Sandbox with the same key; Google reports test purchases from the same API), applies the same ownership rules, and answers 200 with "sandbox": true, "linked": false, subscription_id: null and the entitlements of the mapped plan (each marked "sandbox": true; empty when the purchase is not active / in grace or has expired).

Nothing is recorded: no customer subscription, invoice, payment or link is created, the purchase is not acknowledged with Google, and the entitlements are not stored. They exist only in the app session (the SDK keeps them in memory): GET /mobile-stores/client/entitlements and your backend's entitlement reads do not include them.

Backend register API (POST /mobile-stores/purchases) keeps refusing a sandbox purchase on a Production connection (422 environment mismatch).

Sandbox connection. On a connection set to Sandbox, sandbox purchases are mirrored normally (the answer has "sandbox": false), and a production purchase is refused (backend 422, app 409).

Purchases

Your backend links a store purchase to a SubscriptionFlow customer: use it when the app could not tag the purchase, or to restore purchases. Product and amount always come from the store.

Register a purchase

Links an App Store or Google Play subscription purchase to a customer and mirrors it into SubscriptionFlow (customer subscription, one paid invoice and payment per store charge).

  • Apple: send transaction_id (any transaction of the subscription) or signed_transaction (a StoreKit 2 JWS). When both are sent, signed_transaction is used.
  • Google: send purchase_token. A successful registration also acknowledges the purchase with Google.

The purchase environment must match the connection's environment. If the purchase is already linked to this customer, the call re-syncs it (safe to repeat). If the store product is not mapped yet, the purchase is linked but subscription_id is null until a mapping exists.

Refused with 409 when the purchase is already linked to another customer, or its account token names another customer.

Authorizations:
backendOAuth
Request Body schema: application/json
required
customer_id
required
string <= 64 characters

SubscriptionFlow customer ID to link the purchase to.

store
required
string (Store)
Enum: "apple" "google"

apple (App Store) or google (Google Play).

transaction_id
string or null <= 64 characters ^[0-9]+$

Apple only. Any transaction ID of the subscription (digits). Required for Apple unless signed_transaction is sent.

signed_transaction
string or null <= 20000 characters

Apple only. StoreKit 2 signed transaction (JWS). Used instead of transaction_id when both are sent.

purchase_token
string or null <= 4096 characters

Google only, required. The Play Billing purchase token.

Responses

Request samples

Content type
application/json
Example
{
  • "store": "apple",
  • "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
  • "transaction_id": "2000000123456789"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Token Management

Issue, reuse, rotate and revoke per-customer account tokens. See Account Tokens for the concepts.

Issue an account token

Issues an account token and client secret for a customer, or returns the customer's active token.

  • New token: 201, client_secret set (shown once), reused: false.
  • Active token reused: 200, client_secret: null, reused: true.
  • rotate: true: revokes every active token of the customer and issues a new pair (201).

created_at / revoked_at are ISO 8601 with the server's UTC offset.

Authorizations:
backendOAuth
Request Body schema: application/json
required
customer_id
required
string <= 64 characters

SubscriptionFlow customer ID.

rotate
boolean
Default: false

Revoke the customer's active tokens and issue a new pair.

Responses

Request samples

Content type
application/json
Example
{
  • "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11"
}

Response samples

Content type
application/json
{
  • "status": true,
  • "data": {
    }
}

Revoke an account token

Revokes an account token: the app can no longer authenticate with it. The token keeps identifying its customer for purchase ownership, and linked purchases stay linked. Idempotent: revoking an already revoked token answers 200 with its original revoked_at.

Authorizations:
backendOAuth
path Parameters
accountToken
required
string <uuid>
Example: c92b1774-af5d-4dc4-a375-cf377bfc118a

The account token (UUID, case-insensitive). A value that is not a UUID answers 404.

Responses

Response samples

Content type
application/json
{
  • "status": true,
  • "data": {
    }
}

Entitlements

Cross-channel entitlements for your backend. Define entitlement keys (for example pro) and the plans that grant them on the Entitlements tab of either store settings page. A customer holds an entitlement while any of their subscriptions on one of those plans entitles:

  • App Store / Google Play subscriptions follow the store: active until the store expiry, grace until the store's grace end. On hold, paused, expired and revoked do not entitle, whatever SubscriptionFlow shows.
  • Web subscriptions: Active or Trial (unless cancelled_at has passed), or Cancelled while cancelled_at is still ahead. A web subscription in dunning that is still Active keeps its entitlements.

Each entitlement names the grant that lasts longest (no end first, then the latest expires_at) in source, subscription_id and expires_at, and lists every granting subscription under grants. Times are UTC. expires_at: null means it renews / has no end.

There is no separate change feed: subscribe to SubscriptionFlow's existing subscription webhooks and read entitlements again when one arrives.

Get a customer's entitlements

Returns the customer's current entitlements across web, App Store and Google Play subscriptions, sorted by key. An empty array means the customer holds none (or no entitlement is defined).

Authorizations:
backendOAuth
path Parameters
customer
required
string <uuid>
Example: 9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11

SubscriptionFlow customer ID.

Responses

Response samples

Content type
application/json
{
  • "status": true,
  • "data": [
    ]
}

Usage

When a customer asks Apple for a refund, Apple may send a consumption request. With Answer Apple consumption requests turned on (Apple App Store settings, only with customer consent), SubscriptionFlow answers within Apple's 12-hour window. These endpoints let your backend add the usage values Apple asks for.

Report usage for Apple consumption answers

Stores the values SubscriptionFlow sends to Apple when it answers a consumption request for this customer. Codes are Apple's. Send at least one of consumption_status, play_time, delivery_status; only the fields you send change.

  • Without transaction_id, delivery_status is stored for the customer and applies to all of its later answers.
  • With transaction_id, delivery_status is required and applies to that transaction only; it does not change the customer-level delivery_status.

A purchase is reported as delivered (0) unless you report a delivery problem. The response returns the customer's stored values (0 when never set) and the per-transaction report, if any.

Authorizations:
backendOAuth
path Parameters
customer
required
string <uuid>
Example: 9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11

SubscriptionFlow customer ID.

Request Body schema: application/json
required
consumption_status
integer [ 0 .. 3 ]

Apple consumptionStatus (0 undeclared, 1 not consumed, 2 partially consumed, 3 fully consumed).

play_time
integer [ 0 .. 7 ]

Apple playTime bucket (0 undeclared ... 7 over 2000 hours).

delivery_status
integer [ 0 .. 5 ]

Apple deliveryStatus (0 delivered and working ... 5 other). Required with transaction_id.

transaction_id
string <= 64 characters ^[0-9]+$

Apple transaction ID; delivery_status then applies to that transaction only.

Responses

Request samples

Content type
application/json
Example
{
  • "consumption_status": 2,
  • "play_time": 3
}

Response samples

Content type
application/json
Example
{
  • "status": true,
  • "data": {
    }
}

App Entitlements

The app reads its own customer's entitlements with its account token pair. Same data as the backend Entitlements endpoint.

Get my entitlements (app)

Returns the entitlements of the token's customer. Same shape and rules as GET /mobile-stores/customers/{customer}/entitlements. Sandbox (App Review / TestFlight) entitlements are never included: they live only in the app session.

Rate limits: 60 per minute per token, 3000 per minute per account.

Authorizations:
(accountTokenclientSecret)

Responses

Response samples

Content type
application/json
{
  • "status": true,
  • "data": [
    ]
}

App Purchases

The app (or the Flutter SDK) reports a store purchase right after the store completes it. The SDK completes the store transaction only after a 200.

Report a purchase (app)

Reports a store purchase from the app. The customer is always the token's customer: a customer_id in the body is ignored. SubscriptionFlow verifies the purchase with Apple or Google, then links and mirrors it.

Ownership rules. Anyone using your app holds a token, so the app may only claim purchases that are its own:

  • Tagged purchase (the store reports an account token): links only when the tag is one of this customer's issued tokens (revoked ones included) or, with Require SubscriptionFlow-issued account tokens off, this customer's ID. A tag naming anyone else, or nobody, is refused.
  • Untagged purchase already linked: only its own customer may re-sync it.
  • Untagged Google purchase: links (only the buyer's device knows its purchase token).
  • Untagged Apple purchase: links only from a signed_transaction and only when Let the app SDK claim untagged purchases is on in the Apple settings (off by default). A bare Apple transaction_id never claims an untagged purchase: register those from your backend.

Every refusal answers the same 409, so the answer never reveals whether a purchase exists. Refusals and untagged claims are logged with customer, store reference and IP address.

Sandbox. On a Production connection, a sandbox / TestFlight / App Review / licence-tester purchase is verified but not recorded, and answers 200 with "sandbox": true (see Sandbox and App Review).

Retries. Retry 502, 503 and 429 (honour Retry-After); treat 409 and 422 as final. Reporting the same purchase again is safe.

Rate limits: 30 per minute per token, 1000 per minute per account.

Authorizations:
(accountTokenclientSecret)
Request Body schema: application/json
required
store
required
string (Store)
Enum: "apple" "google"

apple (App Store) or google (Google Play).

transaction_id
string or null <= 64 characters ^[0-9]+$

Apple only. Any transaction ID of the subscription (digits). Required for Apple unless signed_transaction is sent.

signed_transaction
string or null <= 20000 characters

Apple only. StoreKit 2 signed transaction (JWS). Used instead of transaction_id when both are sent.

purchase_token
string or null <= 4096 characters

Google only, required. The Play Billing purchase token.

Responses

Request samples

Content type
application/json
Example
{
  • "store": "apple",
  • "signed_transaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZk..."
}

Response samples

Content type
application/json
Example
{
  • "status": true,
  • "data": {
    }
}

Store Notifications

Inbound server-to-server notifications from Apple and Google. They are served on your tenant domain, not under /api/v1:

  • POST https://demo.subscriptionflow.com/webhook/apple
  • POST https://demo.subscriptionflow.com/webhook/google-play

Configure them in App Store Connect and Google Cloud Pub/Sub; you never call them yourself. The request is verified at receipt, stored, and processed on a queue: SubscriptionFlow re-fetches the authoritative state from the store API (the payload's order is never trusted), deduplicates notifications for 7 days (Apple notificationUUID, Pub/Sub messageId), and retries transient failures. A daily sync re-checks every live subscription to catch missed notifications.

App Store Server Notifications V2 Webhook

URL: POST https://demo.subscriptionflow.com/webhook/apple (tenant domain, not /api/v1).

Set it as the Production Server URL in App Store Connect (App Information > App Store Server Notifications, version 2). Point the Sandbox Server URL only at an account whose Apple connection uses the Sandbox environment; a Production connection does not mirror sandbox notifications.

Verification at receipt: the Apple store must be connected and signedPayload must verify (JWS chain to the pinned Apple Root CA G3). No header or shared secret is used.

Processing (queued): TEST notifications are logged; CONSUMPTION_REQUEST notifications are answered when Answer Apple consumption requests is on; subscription notifications re-fetch the subscription's state from the App Store Server API and sync it. Family-shared purchases are stored but not mirrored. Missed notifications are replayed from Apple's notification history by the daily sync, or on demand from the Apple settings page.

Request Body schema: application/json
required
signedPayload
required
string

Apple's signed notification (JWS).

Responses

Request samples

Content type
application/json
{
  • "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZk..."
}

Response samples

Content type
application/json
{
  • "message": "ok"
}

Google Play real-time developer notifications (Pub/Sub push) Webhook

URL: POST https://demo.subscriptionflow.com/webhook/google-play (tenant domain, not /api/v1).

Create a Pub/Sub push subscription on your RTDN topic with this endpoint, enable authentication with a service account, and set the audience to the webhook URL. Enter the same audience and service account email in the Google Play settings.

Verification at receipt: Google Play must be connected and the Authorization: Bearer <OIDC token> must verify: Google signature, exp, iss accounts.google.com, aud equal to the configured audience, email_verified true and email equal to the configured service account email.

Processing (queued): undecodable messages and messages for another package name are accepted (2xx) and dropped, so Pub/Sub does not redeliver them forever. Test notifications are logged. Subscription notifications acknowledge new purchases (types 1 RECOVERED, 4 PURCHASED, 7 RESTARTED) straight from the message, then re-fetch the subscription from the Play Developer API and sync it. Voided-purchase notifications book refunds.

Authorizations:
pubsubOidc
Request Body schema: application/json
required
required
object
subscription
string

The Pub/Sub subscription name.

Responses

Request samples

Content type
application/json
{
  • "message": {
    },
  • "subscription": "projects/example-app/subscriptions/subscriptionflow-rtdn"
}

Response samples

Content type
application/json
{
  • "message": "ok"
}