Apple In-App Purchases
If your game is on the App Store, players can buy your branded in-game currency with Apple In-App Purchase. They pay Apple through StoreKit, your server sends Apple's signed transaction to INVO, and INVO credits their balance once per Apple transaction. The coins land in the same balance as every other purchase.
Server SDKs cover this flow. @invonetwork/web-sdk (appleProducts / verifyApplePurchase) and invonetwork for Python (apple_products / verify_apple_purchase) implement both requests on this page. See SDK usage. The raw REST shapes are documented here for everyone else.
When to call Transaction.finish()
Every verify-purchase answer, success or error, carries a boolean finish.
finish: truecall
Transaction.finish()finish: falseleave the transaction unfinished and retry
If you get no answer at all (a timeout, a dropped connection), treat it as finish: false. An unfinished transaction is delivered again by StoreKit, your server sends it again, and INVO credits it once. Finishing early is the one way a paid purchase can be lost.
How it works
Apple takes the player's money before INVO sees the purchase. Two rules follow from that:
- • Before every purchase, ask INVO which products are available and offer only those. This is where INVO says no, before the player pays.
- • After payment, a genuine purchase is never refused for your billing. If your billing account cannot cover a purchase that Apple has already charged, the purchase is held, not lost (
503 APPLE_CREDIT_PENDING). Keep it unfinished; it credits once your account is funded.
Products
Your backend calls GET /apple/products. Show only products with available: true.
Purchase
Your app buys the product with StoreKit and gets a verified transaction.
Verify
Your backend sends the signed transaction to POST /apple/verify-purchase. INVO checks it with Apple's signature and credits the coins.
Finish
Your app calls Transaction.finish(), but only when INVO answered finish: true.
Why your backend? Your X-Game-Secret-Key must never ship inside an app. The app talks to your backend, and your backend talks to INVO.
Setting it up
- In App Store Connect: create a consumable In-App Purchase product for each currency pack, for example
com.example.game.coins.100. Only consumables can buy currency. - In the INVO console: make sure billing is set up (see Billing).
- In the INVO console: open the title's Apple settings and enter:
- • the bundle id (for example
com.example.game) and the app's Apple ID (the number App Store Connect shows for the app) - • whether you are in Apple's Small Business Program (it changes Apple's commission, which INVO uses for the proceeds estimate)
- • each product id and the coins it gives, with a label and its US App Store price (optional, recommended)
- • up to 10 App Review accounts: the player emails Apple's reviewers will use (see App Review)
- • the bundle id (for example
- Turn the Apple config on. Saving it with Apple enabled moves your title's iOS key to the Apple rail. Your iOS key then buys only through Apple; turning it off moves the key back. Other keys (web, Android, Steam) are not affected.
- In App Store Connect: under App Store Server Notifications (Version 2), paste the notification URLs the console shows: the production console's URL as the Production Server URL, and the sandbox console's URL as the Sandbox Server URL. Refunds depend on these. Do this before adding the key in the next step: INVO refuses a key for an app with no notification URL (
APPLE_NOTIFICATION_URL_MISSING). - In App Store Connect and the INVO console: create an In-App Purchase API key and add it to the title. It is required to sell on the live App Store, and saving it verifies your app at once (see In-App Purchase API key).
Good to know about the console
- • Only the account owner can save the Apple config.
- • The product list you save is the full list. A product you leave out is no longer offered, but purchases already made of it still credit.
- • One bundle id per title. After the title has sold through Apple, the bundle id cannot change.
- •
PROCEEDS_BELOW_COIN_VALUEis a warning, not an error: Apple's estimated payout for a product is below the coins' value INVO bills you. Storefronts with VAT can trigger it, which is expected. - •
NO_IOS_KEYmeans the title has no iOS key yet; create one.IOS_KEY_HAS_OTHER_OVERRIDEmeans INVO has set your iOS key to a specific rail; contact INVO to move it. - • Selling on the live App Store requires your In-App Purchase API key (see In-App Purchase API key). Purchases themselves are checked against Apple's signature, so no key is involved in crediting a purchase.
In-App Purchase API key
Required to sell on the live App Store. Add your App Store Server API key (App Store Connect calls it an In-App Purchase key) to the title in the production INVO console before you submit the app for review. INVO does not ask for access to your App Store Connect account; the key is all it needs.
What it is and why INVO asks for it
The key lets INVO call Apple's App Store Server API for your app. INVO uses it to:
- • Check transactions independently. INVO can ask Apple directly about a purchase, instead of relying only on what reaches it.
- • Reconcile refunds. Every 6 hours INVO asks Apple about purchases from the last 120 days that are not yet refunded, and takes back the coins for any refund whose notification never arrived. It goes through the same refund handling as a notification, so a refund is never applied twice.
- • Verify your app self-serve. Apple accepts a key only from the App Store Connect account that owns the app, so saving the key proves ownership and verifies the app immediately, with no support ticket.
Create the key in App Store Connect
- Sign in to App Store Connect as the Account Holder or an Admin.
- Open Users and Access, then the Integrations tab.
- Under Keys, choose In-App Purchase.
- Choose Generate In-App Purchase Key (or the + button), give the key a name, and click Generate.
- Note the Issuer ID shown above the key list and the new key's Key ID, then Download the key. App Store Connect lets you download the
.p8file only once; keep it safe until you have pasted it into INVO.
Add it in the INVO console
First save the title's Apple settings (bundle id and products), and paste INVO's notification URLs into App Store Connect (see Setting it up). Then open the In-App Purchase API key panel in the same settings and paste:
- • Issuer ID: the UUID shown above the key list, for example
57246542-96fe-1a63-e053-0824d011072a. - • Key ID: the key's 10-character id, for example
2X9R4HXF34. - • Private key: the full contents of the
.p8file, including the-----BEGIN PRIVATE KEY-----and-----END PRIVATE KEY-----lines.
Only the account owner can add, rotate or remove the key. The key belongs to one INVO title. The production console checks it against Apple's production environment and the sandbox console against Apple's sandbox, so add it to your sandbox title too if you want to try the flow there first.
What happens when you save
- INVO checks the key with Apple before storing anything, by asking Apple to send a test notification for your bundle id. A key Apple refuses is never stored.
- Apple accepts only a key from the App Store Connect account that owns the app, so its acceptance is the proof of ownership. The key is saved and your app is verified at once. There is nothing to wait for. The console shows
APPLE_KEY_ACCEPTED: "Key accepted. Apple confirmed it belongs to this app, and the app is verified."
If another INVO title has already verified the same bundle id, the key is saved but does not verify this title; contact INVO support.
The private key is stored encrypted and is never shown again, not in the console and not in any API response. The console shows only the Issuer ID, the Key ID and when the key was last checked.
Rotate or remove the key
- • Rotate: generate a new key in App Store Connect and save it in the console. It is checked the same way and replaces the old key only once Apple accepts it. Then revoke the old key in App Store Connect.
- • Remove: remove the key in the console and INVO stops using it at once. Purchases keep verifying and crediting, and refund notifications keep working; INVO stops asking Apple directly about your transactions. If you revoke a key in App Store Connect, remove it from the console too. Selling on the live App Store requires a key, so add a new one promptly.
- • Changing the bundle id clears the key (and verification). Upload the key again for the new bundle id.
Errors when saving the key
| Code | HTTP | Meaning / what to do |
|---|---|---|
| APPLE_ISSUER_ID_INVALID | 400 | Not an Issuer ID. Copy the UUID shown above the key list under Users and Access, Integrations, In-App Purchase. |
| APPLE_KEY_ID_INVALID | 400 | Not a Key ID. It is 10 capital letters and digits, shown next to the key in App Store Connect. |
| APPLE_KEY_INVALID | 400 | The private key could not be read. Paste the whole .p8 file, including the BEGIN and END lines. |
| APPLE_KEY_REJECTED | 400 | Apple refused the key for this app. Check that the Issuer ID, Key ID and file belong together, that it is an In-App Purchase key that has not been revoked, and that it comes from the App Store Connect account that owns the bundle id. Nothing was stored. |
| APPLE_NOTIFICATION_URL_MISSING | 400 | App Store Connect has no App Store Server Notifications URL for this app. Paste INVO's notification URL from the console into App Store Connect first, then save the key again. Nothing was stored. |
| APPLE_NOT_CONFIGURED | 409 | Save the title's Apple settings (bundle id and products) before adding the key. |
| (other) | 502 / 503 | Apple or INVO could not complete the check just now. Nothing was stored; try again in a few minutes. |
Step 1: Get the products (backend)
Call this before every purchase, not once at launch. All Apple routes take your iOS key in X-Game-Secret-Key.
GET https://invo.network/api/currency-purchases/apple/products
Headers:
X-Game-Secret-Key: ivsdk_<your_ios_key>
Response 200:
{
"status": "success",
"bundle_id": "com.example.game",
"can_sell": true,
"unavailable_reason": null,
"products": [
{
"product_id": "com.example.game.coins.100",
"coins": 100,
"label": "Pouch of Coins",
"price_usd": "9.99",
"active": true,
"available": true,
"unavailable_reason": null
}
]
}- • Ask StoreKit for exactly the
product_ids listed, and hide or disable any withavailable: false. - •
unavailable_reasonis one of:APPLE_NOT_VERIFIED: production has not verified your app yet (see Verification).PARTNER_BILLING_NOT_SET_UP: your billing account is not set up.PARTNER_RAIL_SUSPENDED: your billing account is suspended.PARTNER_CREDIT_UNAVAILABLE: your prepaid balance cannot cover this product right now.
- • All four are fixed by you in the console, never by the player. Show a plain "not available right now".
Step 2: Buy with StoreKit (app)
Buy the product with StoreKit 2. Optionally set an appAccountToken (a UUID you choose for the player) and send the same value to INVO; INVO then refuses the transaction for any other player. Listen to Transaction.updates from app launch: that is where StoreKit redelivers transactions you left unfinished.
Swift (StoreKit 2)
import StoreKit
final class CoinStore {
private var updates: Task<Void, Never>?
// Start at app launch. StoreKit redelivers unfinished transactions here.
func start() {
updates = Task {
for await result in Transaction.updates {
if case .verified(let tx) = result { await deliver(tx) }
}
}
}
// productIds come from YOUR backend, which called GET /apple/products
// and kept only products with available: true.
func buy(productId: String, playerToken: UUID) async throws {
guard let product = try await Product.products(for: [productId]).first
else { return }
let result = try await product.purchase(options: [.appAccountToken(playerToken)])
if case .success(.verified(let tx)) = result {
await deliver(tx)
}
// .pending, .userCancelled: nothing to do yet.
}
private func deliver(_ tx: Transaction) async {
// YOUR backend forwards this to INVO's /apple/verify-purchase.
// Your backend passes INVO's "finish" flag back to the app.
let answer = try? await MyGameBackend.verifyApplePurchase(
signedTransaction: tx.jwsRepresentation)
if answer?.finish == true {
await tx.finish()
}
// finish false, or no answer at all: do NOT finish. StoreKit
// redelivers it and your backend retries. INVO credits it once.
}
}Purchases made with Xcode's local StoreKit testing are not signed by Apple and are never accepted. Test with an App Store sandbox account against INVO sandbox.
Step 3: Verify the purchase (backend)
Your backend sends Apple's signed transaction (Transaction.jwsRepresentation) to INVO. Call it from your server, never from the device.
POST https://invo.network/api/currency-purchases/apple/verify-purchase
Headers:
X-Game-Secret-Key: ivsdk_<your_ios_key>
Content-Type: application/json
Body:
{
"player_email": "player@example.com",
"signed_transaction": "<Transaction.jwsRepresentation>",
"app_account_token": "8f14e45f-ceea-467a-9575-6b2f5f2d4c1e", // optional
"metadata": { "my_player_id": "p_123" } // optional, echoed on webhooks
}
Response 200:
{
"status": "credited", // or "already_credited"
"order_id": "ORD_...",
"transaction_id": "2000000123456789",
"product_id": "com.example.game.coins.100",
"coins": 100,
"balance": "1250.00",
"environment": "Production",
"test_mode": false,
"finish": true
}player_email(required): the INVO player who receives the coins.signed_transaction(required): the transaction exactly as StoreKit gave it. You never send a price or a coin amount; the product mapping in the console decides the coins.app_account_token(optional): when sent, it must match the transaction'sappAccountToken.metadata(optional, JSON object): echoed onpurchase.completedandpurchase.refunded.status(response):credited, oralready_creditedfor a repeat of a transaction INVO has already credited. On a repeat,balanceisnullunless the call names the same player.test_mode(response):truefor an Apple Sandbox transaction.finish(response):truemeans callTransaction.finish(). It is on every answer, including errors.
The call is idempotent on the Apple transaction. Send the same transaction as often as you like; it credits once.
Error codes
Every error has the same shape:
{
"status": "error",
"error_code": "APPLE_PRODUCT_UNKNOWN",
"message": "...",
"finish": false
}Read finish, not the code, to decide. Where finish is false, fix what the code names and the next redelivery credits.
| Code | HTTP | Meaning / what to do | finish |
|---|---|---|---|
| APPLE_SIGNATURE_INVALID | 400 | Not a genuine Apple transaction. | false |
| APPLE_BUNDLE_MISMATCH | 400 | The transaction belongs to another app. Check the bundle id in the console. | false |
| APPLE_ENVIRONMENT_NOT_ALLOWED | 400 | Wrong environment for this INVO deployment: INVO sandbox takes only Apple Sandbox transactions. Xcode StoreKit testing transactions are never accepted. | false |
| APPLE_PRODUCT_TYPE_UNSUPPORTED | 400 | Only consumables buy currency. | false |
| APPLE_PRODUCT_UNKNOWN | 400 | The product id is not registered for this title. Add it in the console; the retry credits. | false |
| INVALID_METADATA / INVALID_INPUT | 400 | Malformed request body: an integration bug. | false |
| APPLE_REVIEW_ACCOUNT_REQUIRED | 403 | An Apple Sandbox purchase on production by a player who is not on the title's App Review list. | false |
| APPLE_TRANSACTION_REVOKED | 409 | Apple refunded or revoked it. Nothing will ever be delivered. | true |
| APPLE_TRANSACTION_ALREADY_USED | 409 | This transaction was already credited in another INVO title. It can never credit here. | true |
| APPLE_TRANSACTION_TOO_OLD | 409 | Bought before the title sold through INVO. It can never credit. | true |
| APPLE_VALUE_EXCEEDS_PRICE | 409 | The product's coins are out of proportion to its price; fix the product mapping in the console. The retry credits. | false |
| APPLE_ACCOUNT_TOKEN_MISMATCH | 409 | The transaction was made for a different player. | false |
| APPLE_NOT_CONFIGURED / APPLE_NOT_VERIFIED | 409 | The Apple config is off, or production has not verified the app yet. Retry once fixed. | false |
| RAIL_CHANNEL_MISMATCH | 409 | The key you used is not on the Apple rail. Use the iOS key with the Apple config turned on. | false |
| APPLE_REVIEW_PURCHASE_LIMIT | 429 | Today's App Review limit on production is used up. | false |
| APPLE_CREDIT_PENDING | 503 | Verified and held, not lost. Your billing account cannot cover it yet, or payments are paused or under maintenance. Keep it unfinished; it credits on a later retry once your account is funded or service resumes. | false |
A card purchase (/purchase-currency) sent with a key on the Apple rail answers 400 WRONG_RAIL_ENDPOINT with status: "use_apple_endpoint".
Webhooks
purchase.completed and purchase.refunded fire for Apple purchases like any other. They carry rail: "apple", channel: "ios", your metadata, and an apple block:
"rail": "apple",
"channel": "ios",
"metadata": { "my_player_id": "p_123" },
"apple": {
"transaction_id": "2000000123456789",
"original_transaction_id": "2000000123456789",
"product_id": "com.example.game.coins.100",
"environment": "Production",
"storefront": "USA",
"price": "9.99",
"currency": "USD",
"charged_usd_estimated": false,
"test_mode": false
}- •
usd_amount/charge_total_usdis what the player paid in USD. It is exact on the US storefront. On other storefronts it is the product's US price from the console, andcharged_usd_estimatedistrue. - •
priceis the per-unit price in the storefront's currency (currency).
Refunds
Apple refunds are handled automatically through the notification URLs you set in App Store Connect. You do not call anything. When Apple refunds or revokes a purchase, INVO takes back the coins and fires purchase.refunded with rail: "apple".
- • Only unspent coins from that purchase are taken back. A balance never goes below zero.
- • Your billing account is credited back only for the coins actually taken back.
- • With your In-App Purchase API key added, INVO also asks Apple every 6 hours about purchases from the last 120 days and applies any refund whose notification never arrived.
- • If Apple refunds a purchase before your server reports it, INVO does not credit it, and verify-purchase answers
APPLE_TRANSACTION_REVOKED. - • A reversed refund is reviewed by INVO; the coins are not re-issued automatically.
Verification
Production sales start once your app is verified. Adding your In-App Purchase API key verifies it immediately: Apple accepts the key only from the account that owns the app, so its acceptance is the proof, with no support step. Do this before App Review, so review purchases work. Any genuine notification from Apple to your notification URL also verifies the app. Until your app is verified, GET /apple/products answers available: false with APPLE_NOT_VERIFIED.
If you cannot add the key, or your app is still not verified after you add it, ask INVO support to verify it. That is the fallback, not the normal path.
INVO sandbox does not require verification. Changing the bundle id clears verification and the key; upload the key again.
Sandbox testing and App Review
INVO sandbox (routes under the /sandbox prefix, for example https://sandbox.invo.network/sandbox/api/currency-purchases/apple/products) accepts Apple Sandbox transactions only. Use App Store sandbox accounts. Request and response shapes are the same as production. See Sandbox Testing.
INVO production accepts Apple Production transactions. Apple's reviewers buy with Apple Sandbox accounts against your production build, so production also accepts Sandbox transactions for App Review, as test_mode, with limits:
- • only for the player emails on the title's App Review list in the console (up to 10);
- • one item per purchase;
- • at most 20 purchases and $100 of coin value per title per UTC day.
Add your In-App Purchase API key before you submit, so your app is verified (see Verification). Give Apple's reviewers a login for one of those emails in your App Review notes. App Review purchases are billed like any other purchase.
Billing
Apple pays you for In-App Purchases. INVO issues the coins and bills you for their value through your billing account in the INVO console. That account is prepaid: products are offered only while your balance covers them, which is why GET /apple/products can answer PARTNER_CREDIT_UNAVAILABLE.
Apple is the merchant of record and handles tax on the sale. Apple's commission is between you and Apple; INVO adds no other fee on this rail.
SDK usage (server)
TypeScript (@invonetwork/web-sdk)
// Before every purchase: offer only available products.
const { products } = await invo.appleProducts();
const offer = products.filter((p) => p.available).map((p) => p.productId);
// After StoreKit returns a verified transaction:
const result = await invo.verifyApplePurchase({
playerEmail: "player@example.com",
signedTransaction: jwsFromApp, // Transaction.jwsRepresentation
appAccountToken: playerUuid, // optional
metadata: { myPlayerId: "p_123" }, // optional, echoed on webhooks
});
// Pass result.finish back to the app. Errors carry finish too:
// finish true -> the app calls Transaction.finish(); false -> leave it and retry.Python (invonetwork)
products = invo.apple_products()
offer = [p["product_id"] for p in products["products"] if p["available"]]
result = invo.verify_apple_purchase(
player_email="player@example.com",
signed_transaction=jws_from_app, # Transaction.jwsRepresentation
app_account_token=player_uuid, # optional
metadata={"my_player_id": "p_123"}, # optional
)
# Pass result["finish"] back to the app. Errors carry finish too:
# True -> the app calls Transaction.finish(); False -> leave it unfinished.Good to know
Server-side only
Both /apple/* calls need your X-Game-Secret-Key and must come from your backend. The app only talks to StoreKit and to your backend.
One balance, every rail
Apple-bought coins land in the same balance as card-bought coins and move the same way. On a title with a separate Steam balance, they are not spendable in the Steam build.
Redelivery is your retry
An unfinished transaction comes back through Transaction.updates. Leave it unfinished on any error and it reaches INVO again.
Idempotent by design
Each Apple transaction credits once, in one INVO title. Repeats answer already_credited.
Apple's signature decides
INVO checks every transaction against Apple's signature, your bundle id and the environment before crediting. You never send a price or coin amount.
Buying on the web
A link that sends US players to buy on your website needs nothing new: open INVO's hosted checkout in Safari with your title's web key.