Download OpenAPI specification:Download
Sell App Store subscriptions with StoreKit 2 and track customers, invoices and entitlements in SubscriptionFlow.
Sell auto-renewable subscriptions with StoreKit 2. SubscriptionFlow tracks every customer, invoice, refund and entitlement. There is no SDK to install: your app makes two HTTPS calls.
https://<your-subscriptionflow-domain>/api/v1/mobile-stores/clientRequirements: iOS 15 or later · StoreKit 2 + Swift concurrency · No SDK to install
Your backend, the store and SubscriptionFlow each do one part. Your SubscriptionFlow API credentials never leave your server.
It asks SubscriptionFlow for an account token and client secret for the signed-in customer and hands them to the app.
The purchase goes through the store, tagged with the account token (as StoreKit's appAccountToken), so Apple or Google keep it attached to that customer.
The app reports the purchase. SubscriptionFlow checks it with Apple or Google and creates the subscription, a Paid invoice and a payment.
Only then the app finishes the purchase and unlocks features from the customer's entitlements, web subscriptions included.
Do this once, in your SubscriptionFlow account.
Under Settings > Marketplace > Integrations, connect Apple App Store and/or Google Play and set the notification URL in the store console.
Map every store product (and Google base plan) to a SubscriptionFlow plan price on the same page.
On the Entitlements tab, add keys such as pro and tick the plans that grant them.
For your backend: client-credentials grant, scope access-global-api.
Your backend issues one pair per customer with its SubscriptionFlow API token. Never call this from the app. Full reference: Issue an account token.
POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/account-tokens
Authorization: Bearer <access_token>
Content-Type: application/json
{ "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11" }
{
"status": true,
"data": {
"account_token": "72b8598b-8706-4a5e-96a2-52abbd0400cb",
"customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
"created_at": "2026-11-03T09:00:00+00:00",
"revoked_at": null,
"client_secret": "OOkXxkYIfion1fcWJqL1GIR5tdbVqMFI5wlABZafEJKlI8EO",
"reused": false
}
}
Store it on your backend. Later calls answer 200 with the same account_token, client_secret: null and reused: true.
Hand the pair to each device of that customer through an endpoint of your own, behind your user login. Keep it in secure storage on the device.
Send "rotate": true after a leak, on account deletion or if you lost the secret. Never as a device refresh: rotating logs out the customer's other devices.
StoreKit 2 requires the appAccountToken to be a UUID. SubscriptionFlow account tokens are UUIDs.
import StoreKit
struct SFPair { let accountToken: String; let clientSecret: String }
func buy(productId: String, pair: SFPair) async throws {
guard let product = try await Product.products(for: [productId]).first,
let token = UUID(uuidString: pair.accountToken) else { return }
let result = try await product.purchase(options: [.appAccountToken(token)])
switch result {
case .success(let verification):
await handle(verification, pair: pair)
case .pending, .userCancelled:
break
@unknown default:
break
}
}
Send the signed transaction (JWS). SubscriptionFlow verifies it with Apple. Full reference: Report a purchase (app).
let baseURL = URL(string: "https://<your-subscriptionflow-domain>")!
func handle(_ verification: VerificationResult<Transaction>, pair: SFPair) async {
var request = URLRequest(url: baseURL.appendingPathComponent("api/v1/mobile-stores/client/purchases"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(pair.accountToken, forHTTPHeaderField: "X-SF-Account-Token")
request.setValue(pair.clientSecret, forHTTPHeaderField: "X-SF-Client-Secret")
request.httpBody = try? JSONSerialization.data(withJSONObject: [
"store": "apple",
"signed_transaction": verification.jwsRepresentation,
])
guard let (_, response) = try? await URLSession.shared.data(for: request),
let status = (response as? HTTPURLResponse)?.statusCode else {
return // network error: leave it unfinished, StoreKit delivers it again
}
switch status {
case 200:
await verification.unsafePayloadValue.finish() // linked: unlock features
case 409, 422:
// Refused for good: tell the user / support, then finish so it is not redelivered.
await verification.unsafePayloadValue.finish()
case 401:
break // fetch the current pair from your backend and call handle() again once
default:
break // 429 / 5xx: leave unfinished and try again later
}
}
Renewals, purchases approved later (Ask to Buy) and purchases left unfinished arrive through Transaction.updates.
Start listening as soon as the app launches.
func listenForTransactions(pair: SFPair) -> Task<Void, Never> {
Task.detached {
for await verification in Transaction.updates {
await handle(verification, pair: pair)
}
}
}
For a Restore purchases button, sync with the App Store and report the current transactions again. Reporting twice never creates duplicates.
func restore(pair: SFPair) async throws {
try await AppStore.sync()
for await verification in Transaction.currentEntitlements {
await handle(verification, pair: pair)
}
}
Unlock features from the entitlements, not from product ids: they also cover web and Google Play subscriptions. Full reference: Get my entitlements (app).
GET https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/entitlements
X-SF-Account-Token: <account_token>
X-SF-Client-Secret: <client_secret>
{
"status": true,
"data": [
{
"key": "pro",
"name": "Pro",
"source": "AppStore",
"subscription_id": "5faf8...",
"expires_at": "2026-11-28T09:00:00+00:00",
"grants": [
{ "source": "AppStore", "subscription_id": "5faf8...",
"status": "Active", "expires_at": "2026-11-28T09:00:00+00:00" }
]
}
]
}
web, AppStore or GooglePlay. expires_at is null while a subscription renews. All times are UTC.What the purchase report can answer, and what the app should do.
Safe to repeat. Includes the fresh entitlements: complete the purchase and unlock. With "sandbox": true it was a sandbox, TestFlight or licence-tester purchase: verified, nothing recorded.
The token pair is wrong or revoked. Fetch the current pair from your backend once and retry.
"This purchase cannot be linked to this account", a validation error, a store that is not connected, or a purchase that needs review. Show a message, then complete the purchase.
Rate limit, temporary problem or store outage. Leave the transaction unfinished; StoreKit redelivers it through Transaction.updates. Retry with backoff and honour Retry-After.
Anyone using your app holds a token, so SubscriptionFlow only links purchases that belong to the caller.
POST /api/v1/mobile-stores/purchases.The app endpoints have their own limits and never use your REST API quota. Over a limit the answer is
429 with Retry-After.
| Call | Endpoint | Limit |
|---|---|---|
| Report a purchase | POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/purchases |
30 per minute per token, 1000 per minute per account |
| Read entitlements | GET https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/entitlements |
60 per minute per token, 3000 per minute per account |
| Wrong token or secret | Headers X-SF-Account-Token, X-SF-Client-Secret |
30 per minute per device IP address |
| Issue or rotate a pair (backend) | POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/account-tokens |
Uses your REST API token |
Use a SubscriptionFlow account whose Apple connection is set to Sandbox, and buy on a real device signed in with a Sandbox tester.
They use the sandbox. On a Production connection SubscriptionFlow answers 200 with "sandbox": true and the entitlements of the mapped plan, but records nothing. Unlock the feature for that session only.
Purchases from a local StoreKit configuration file are not signed by Apple, so they cannot be verified. This is by design.
No. Reports are idempotent, so restore and retries are always safe.
Open a support ticket with the store, the environment and the last 6 characters of the transaction id or purchase token.
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
}
}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"
}
]
}
]
}
}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"
}
]
}
]
}