# 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:

1. 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.
2. 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.
3. 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:

```json
{
  "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(_:)`](https://developer.apple.com/documentation/storekit/product/purchaseoption/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:

1. Verify the transaction and its Bundle ID, environment, product, state, and `appAccountToken`.
2. Require `appAccountToken` to equal the current session's `appUserUUID`.
3. Resolve the Speed credit amount from its own product mapping. Never accept the amount from the client.
4. Record the Apple transaction ID in its own purchase ledger.
5. Apply the credit to Speed using that transaction ID as the idempotency key.
6. Finish the StoreKit transaction after the credit has been applied or recognized as an earlier successful retry.

Apple's [`Transaction`](https://developer.apple.com/documentation/storekit/transaction) documentation describes the signed JWS and server-verification options.

## Apply a top-up

Call this endpoint from the app backend:

```http
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:

```json
{
  "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`](https://developer.apple.com/documentation/storekit/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:

```http
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:

```json
{
  "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](./Apple-Sign-In.md#account-deletion) for the account flow.
