Raw Markdown for agents: https://speed.so/docs/agents/Apple-IAP.md
Apple In-App Purchases
This guide covers the Speed-specific parts of selling consumable credits with StoreKit. It assumes you know how to configure products, purchase them, verify StoreKit transactions, and process transaction updates; it does not repeat Apple's documentation.
Only top-ups are covered. Refunds and balance reversals are not part of this API.
How it works
Set the app's usage source to Local in the Speed dashboard.
An app-local balance belongs to one user in one app and is denominated in Speed's USD resource-cost unit. Your app decides how products and displayed credits map to that amount. Speed does not receive your App Store credentials, product configuration, receipt, or signed transaction.
For each billable operation:
- If the app-local balance and the app owner's Speed funds are both greater than zero, Speed uses the local balance and charges the actual resource cost to the app owner.
- If the local balance is zero or negative, a linked Speed account may pay instead, provided that the user granted this app access to its standard balance.
- Otherwise, Speed rejects the operation.
The preflight check only tests whether the selected balances are positive. Speed does not reserve an estimated cost, so the completed operation may make the local balance, payer balance, or grant negative. That prevents the next operation from starting.
Account identifiers
Every Speed authentication response with an app context contains:
{
"token": "<Speed JWT>",
"user": { "id": "<app-facing user ID>" },
"appUserUUID": "3d4c5b2d-0000-5000-8000-000000000000"
}
Use user.id when calling the Speed balance API. Pass appUserUUID to StoreKit as the purchase's appAccountToken. Apple returns the same UUID in the transaction, allowing your backend to bind the purchase to the current Speed app account. See Apple's appAccountToken(_:) documentation.
appUserUUID is a correlation identifier, not a credential. It remains stable while the app-facing user ID remains stable.
Purchase flow
After StoreKit returns a verified purchase, send its signed transaction to your app backend over the user's authenticated session. The backend must:
- Verify the transaction and its Bundle ID, environment, product, state, and
appAccountToken. - Require
appAccountTokento equal the current session'sappUserUUID. - Resolve the Speed credit amount from its own product mapping. Never accept the amount from the client.
- Record the Apple transaction ID in its own purchase ledger.
- Apply the credit to Speed using that transaction ID as the idempotency key.
- Finish the StoreKit transaction after the credit has been applied or recognized as an earlier successful retry.
Apple's Transaction documentation describes the signed JWS and server-verification options.
Apply a top-up
Call this endpoint from the app backend:
POST https://api.speed.so/api/platform/apps/self/users/<user.id>/local-balance/adjustments
X-Speed-App-Id: <SPEED_APP_ID>
X-Speed-App-Key: <SPEED_APP_KEY>
Content-Type: application/json
{
"kind": "purchase",
"amount": "0.85000000",
"idempotencyKey": "apple-transaction:2000000123456789",
"metadata": {
"source": "apple-iap",
"productId": "com.example.credits.small"
}
}
SPEED_APP_ID and SPEED_APP_KEY are server credentials. Speed-hosted backends receive them as environment variables. Do not expose the App Key to the iOS app.
amount is a positive decimal string with at most eight decimal places. It is the Speed credit assigned to the product by your backend, not the product's App Store price.
Response:
{
"adjustmentId": "12345",
"applied": true,
"localBalance": {
"amount": "1.85000000",
"unit": "USD"
}
}
The idempotency key is unique within the app. Repeating the same key, user, kind, and amount returns HTTP 200 with applied: false; it does not add the credit again. Reusing the key with a different user, kind, or amount returns HTTP 409 idempotency_conflict.
Errors
400 | Invalid request or amount. |
401 | Missing or invalid App ID/App Key. |
404 | The target user is not registered with this app. |
409 idempotency_conflict | The key was previously used with different adjustment data. |
Transaction recovery
Process both Transaction.unfinished and Transaction.updates. Delivery may run more than once, so use the same Apple transaction ID on every retry and finish the transaction only after Speed returns success. Apple's Transaction.unfinished documentation explains the recovery sequence.
Before completing an account link with keep: "speed", deliver unfinished purchases for the old session. That link mode changes the app-facing user ID and appUserUUID; future purchases use the identifiers returned by the link response.
Reading balances
Use the user's Speed JWT:
GET https://api.speed.so/api/platform/resources/grants/self/token
Authorization: Bearer <Speed JWT>
X-Speed-Request-From: app
The existing grant response additionally contains:
{
"grant": {
"spendableAmount": "0.35000000",
"unit": "USD"
},
"localBalance": {
"amount": "0.85000000",
"unit": "USD"
}
}
localBalance.amount is the balance belonging to this app account. grant.spendableAmount is what the linked standard Speed account can currently spend after both its grant and account funds are considered; it is zero for an unlinked app-local account.
Both values are decimal strings. Converting them into the app's displayed credit unit is application logic.
Account linking and deletion
With keep: "local", the app-facing user ID, local balance, and appUserUUID stay unchanged.
With keep: "speed", Speed merges the local balance into the surviving account and returns a new token, user ID, and appUserUUID. Replace all three values in the app session.
Deleting the app account deletes its app-local balance. See Sign in with Apple for the account flow.