Sign in
Signing in on mobile is the flow and Apple Sign-In the provider; choosing a client credential is why it ends in a bearer token; selling on the App Store is the purchase half.
Passwordless, always. There is no emailAndPassword anywhere in the kit.
Two flows, both through the auth capability’s Better Auth surface: a magic link, or an emailed one-time code — six digits by default, five minutes, single-use regardless.
A code is usually the better fit on a phone, because a magic link opening in the wrong browser is the classic mobile sign-in dead end.
Apple Sign-In is available as a social provider. Enable it, then store the credentials as one atomic secret — the Services id, the client secret, and optionally the app bundle id.
Hold two credentials
An access token for Authorization: Bearer, and a refresh credential you rotate on use.
Keep both in the Keychain. The refresh credential is the one worth protecting — the access token expires on its own.
Rotate, and handle the two failures differently
POST /auth/token/rotateA 401 you can recover from: rotate and retry once.
A 401 after rotation fails means sign in again.
Do not loop. A client that refreshes on every 403 will refresh a perfectly good token forever — 401 and 403 are the pair worth separating, and treating them the same is the most common client bug against this API.
Register the device
x-pithy-device-id: <a stable id you generate and keep>
x-pithy-platform: ios
x-pithy-device-name: …
x-pithy-os-version: …
x-pithy-app-version: …Only the id is required. The rest is best-effort, and a sparse re-login never overwrites richer detail you sent before.
Generate the id once and keep it stable. A value that changes per launch turns the user’s device list into something nobody can read.
Buying things
Submit the receipt to your own Worker the moment a purchase completes:
POST /payments/purchasesThe entitlement appears immediately rather than waiting for Apple’s webhook — and when the webhook lands it finds the row already there, because the write path is idempotent on (rail, providerTransactionId).
A replay by its own owner is a 200 with the existing purchase. A receipt belonging to somebody else is not.
Gate on the entitlement key, never the SKU:
GET /payments/entitlementsTranslate errors on the client
t.maybe(payload.code, payload.params) ?? payload.messageThe server’s message is English, permanently — it is the operator’s diagnostic and the fallback for a client that cannot do better.
The code is the translation key, and params is what lets you build the sentence in any language.
Better Auth’s own refusals are the exception and arrive already translated server-side, in the locale this project negotiated — so a mistyped one-time code reads in the user’s language without your app shipping a catalog for it.
Uploading
Ask your Worker for a presigned URL, PUT the bytes straight to R2, then complete. The bytes never pass through your Worker.
Above 100 MiB it goes multipart, and a dropped upload resumes by re-listing parts rather than starting over.