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.
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).POST /mobile-stores/account-tokens and hands it to the app, which sets it as appAccountToken (iOS) or
obfuscatedExternalAccountId (Android).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.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.
| 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.
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 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.
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.
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.200 with the same
account_token, client_secret: null and reused: true."rotate": true to revoke every active token of the customer and get a new pair (201).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.
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.
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.
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).
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.
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).
transaction_id (any transaction of the subscription) or signed_transaction
(a StoreKit 2 JWS). When both are sent, signed_transaction is used.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.
| customer_id required | string <= 64 characters SubscriptionFlow customer ID to link the purchase to. |
| store required | string (Store) Enum: "apple" "google"
|
| transaction_id | string or null <= 64 characters ^[0-9]+$ Apple only. Any transaction ID of the subscription (digits). Required for Apple unless |
| signed_transaction | string or null <= 20000 characters Apple only. StoreKit 2 signed transaction (JWS). Used instead of |
| purchase_token | string or null <= 4096 characters Google only, required. The Play Billing purchase token. |
{- "store": "apple",
- "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
- "transaction_id": "2000000123456789"
}{- "data": {
- "store_subscription_id": "8517ca7d-6be2-40f1-81e1-69f436283612",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "status": "active",
- "linked": true
}
}Issue, reuse, rotate and revoke per-customer account tokens. See Account Tokens for the concepts.
Issues an account token and client secret for a customer, or returns the customer's active token.
201, client_secret set (shown once),
reused: false.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.
| customer_id required | string <= 64 characters SubscriptionFlow customer ID. |
| rotate | boolean Default: false Revoke the customer's active tokens and issue a new pair. |
{- "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11"
}{- "status": true,
- "data": {
- "account_token": "c92b1774-af5d-4dc4-a375-cf377bfc118a",
- "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
- "created_at": "2026-10-28T10:14:02+01:00",
- "revoked_at": null,
- "client_secret": null,
- "reused": true
}
}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.
| accountToken required | string <uuid> Example: c92b1774-af5d-4dc4-a375-cf377bfc118a The account token (UUID, case-insensitive). A value that is not a UUID answers |
{- "status": true,
- "data": {
- "account_token": "c92b1774-af5d-4dc4-a375-cf377bfc118a",
- "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
- "created_at": "2026-10-28T10:14:02+01:00",
- "revoked_at": "2026-10-29T08:30:00+01:00"
}
}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:
active until the store expiry, grace until the
store's grace end. On hold, paused, expired and revoked do not entitle, whatever SubscriptionFlow shows.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.
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).
| customer required | string <uuid> Example: 9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11 SubscriptionFlow customer ID. |
{- "status": true,
- "data": [
- {
- "key": "pro",
- "name": "Pro features",
- "source": "AppStore",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "expires_at": "2026-11-28T09:14:02+00:00",
- "grants": [
- {
- "source": "AppStore",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "status": "Active",
- "expires_at": "2026-11-28T09:14:02+00:00"
}, - {
- "source": "web",
- "subscription_id": "5c86c674-752c-49ea-b216-7e374e0250c7",
- "status": "Cancelled",
- "expires_at": "2026-11-03T23:00:00+00:00"
}
]
}
]
}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.
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.
transaction_id, delivery_status is stored for the customer and applies to all of its later
answers.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.
| customer required | string <uuid> Example: 9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11 SubscriptionFlow customer ID. |
| consumption_status | integer [ 0 .. 3 ] Apple |
| play_time | integer [ 0 .. 7 ] Apple |
| delivery_status | integer [ 0 .. 5 ] Apple |
| transaction_id | string <= 64 characters ^[0-9]+$ Apple transaction ID; |
{- "consumption_status": 2,
- "play_time": 3
}{- "status": true,
- "data": {
- "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
- "consumption_status": 2,
- "play_time": 3,
- "delivery_status": 0,
- "transaction": null
}
}The app reads its own customer's entitlements with its account token pair. Same data as the backend Entitlements endpoint.
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.
{- "status": true,
- "data": [
- {
- "key": "pro",
- "name": "Pro features",
- "source": "GooglePlay",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "expires_at": "2026-11-28T09:14:02+00:00",
- "grants": [
- {
- "source": "GooglePlay",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "status": "Active",
- "expires_at": "2026-11-28T09:14:02+00:00"
}
]
}
]
}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.
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:
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.
| store required | string (Store) Enum: "apple" "google"
|
| transaction_id | string or null <= 64 characters ^[0-9]+$ Apple only. Any transaction ID of the subscription (digits). Required for Apple unless |
| signed_transaction | string or null <= 20000 characters Apple only. StoreKit 2 signed transaction (JWS). Used instead of |
| purchase_token | string or null <= 4096 characters Google only, required. The Play Billing purchase token. |
{- "store": "apple",
- "signed_transaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZk..."
}{- "status": true,
- "data": {
- "store_subscription_id": "8517ca7d-6be2-40f1-81e1-69f436283612",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "status": "active",
- "linked": true,
- "sandbox": false,
- "entitlements": [
- {
- "key": "pro",
- "name": "Pro features",
- "source": "AppStore",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "expires_at": "2026-11-28T09:14:02+00:00",
- "grants": [
- {
- "source": "AppStore",
- "subscription_id": "409de83a-9854-49f6-9de7-1cb6105a1dd2",
- "status": "Active",
- "expires_at": "2026-11-28T09:14:02+00:00"
}
]
}
]
}
}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/applePOST https://demo.subscriptionflow.com/webhook/google-playConfigure 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.
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.
| signedPayload required | string Apple's signed notification (JWS). |
{- "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZk..."
}{- "message": "ok"
}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.
required | object |
| subscription | string The Pub/Sub subscription name. |
{- "message": {
- "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzYxNzI4NDQyMDAwIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiIxLjAiLCJub3RpZmljYXRpb25UeXBlIjo0LCJwdXJjaGFzZVRva2VuIjoia2dqa2htYmhjcGxlZ25mcGtjbmxibWRnIiwic3Vic2NyaXB0aW9uSWQiOiJwcm9fbW9udGhseSJ9fQ==",
- "messageId": "13684937012345678",
- "publishTime": "2026-10-29T09:14:02.123Z"
}, - "subscription": "projects/example-app/subscriptions/subscriptionflow-rtdn"
}{- "message": "ok"
}