app.ski documentation
Sell digital goods from your iOS and Android apps through a web checkout, and keep access in sync.
Overview
Your app opens a checkout session for a product. The buyer pays on a hosted page, which uses Stripe Checkout with Apple Pay, Google Pay and cards. Then they're sent back to your app with your return URL. app.ski records a purchase, which grants an entitlement such as pro to your app user ID.
- Publishable key
pk_…: goes in the app. It can list products, open checkouts and read entitlements. - Secret key
sk_…: server only. It adds purchase history, refunds, cancellations and events. - Product types:
subscription(weekly, monthly or yearly),non_consumable(lifetime unlock),consumable(coins and credits; grant them from the webhook).
Store rules (read this)
Rules for linking to outside payments depend on the store and the country, and they keep changing. In short, as of late 2025:
- App Store, United States: since the April 2025 Epic v. Apple injunction, apps may include buttons and links to web purchases. No StoreKit External Purchase entitlement or commission is needed for US storefronts.
- App Store, EU: the DMA's alternative terms allow link-outs through the StoreKit External Purchase Link entitlement, with Apple's own fees and disclosure sheet.
- App Store, elsewhere: “reader” apps and some other cases are allowed. Otherwise, external purchase links are generally not permitted.
- Google Play: US apps may link to outside payments under the Epic v. Google remedies, and the EEA and other markets have external offers or user-choice billing programs. Enroll in the right program for each region.
Check the current Apple and Google guidelines for every storefront you ship to, and gate the web-purchase button by region where you must. Each purchase records its platform (ios, android, web) for store reporting.
Quickstart
- Create an account and an app. Save the secret key it shows you.
- Add a product, for example
pro_monthly: a $4.99 monthly subscription with entitlementpro. - Set a return URL: a custom scheme like
myapp://purchased, or a universal link / app link. - Add the SDK, call
purchase("pro_monthly"), and checkentitlements["pro"]. - Optional: set a webhook URL so your backend hears about purchases, renewals and refunds.
iOS (Swift)
Add the Swift package in sdk/ios. Register your return URL scheme under URL Types.
import AppSki
AppSki.configure(apiKey: "pk_live_…", appUserID: user.id, returnURL: "myapp://purchased")
Button("Continue on web") {
Task {
let result = try await AppSki.shared.purchase("pro_monthly")
if result.isActive("pro") { unlockPro() }
}
}
// On launch / foreground
let ents = try await AppSki.shared.entitlements()
// If you use presentation = .externalBrowser
.onOpenURL { AppSki.shared.handle($0) }
By default, checkout opens in an ASWebAuthenticationSession. It's Safari-backed, so Apple Pay works, and it closes itself when the return URL opens.
Android (Kotlin)
Include the sdk/android/appski module. Add an intent filter for your return URL on the purchasing activity (launchMode="singleTop").
AppSki.configure(this, apiKey = "pk_live_…", appUserId = user.id, returnUrl = "myapp://purchased")
lifecycleScope.launch {
val result = AppSki.purchase(this@MainActivity, "pro_monthly")
if (result.isActive("pro")) unlockPro()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
AppSki.handle(intent)
}
<intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="myapp" android:host="purchased" /> </intent-filter>
React Native / Expo
import AppSki from '@appski/react-native'; // sdk/react-native
AppSki.configure({ apiKey: 'pk_live_…', appUserId: user.id, returnUrl: 'myapp://purchased' });
const { completed, entitlements } = await AppSki.purchase('pro_monthly');
With expo-web-browser installed, checkout runs in an auth session that closes on return. Without it, the SDK opens the system browser.
API reference
Base URL https://app.ski. Send Authorization: Bearer <key> and JSON bodies. Errors look like {"error": "…"}.
Active products, with display_price for your paywall.
{ "product": "pro_monthly", "app_user_id": "user_42",
"platform": "ios", "email": "optional@buyer.com", "return_url": "myapp://purchased" }
→ 201 { "id": "cs_…", "url": "https://app.ski/c/cs_…", "status": "open", "expires_at": "…" }
Open url in a browser. Sessions expire after 24 hours. You get a 409 if the user already owns this non-consumable or subscription. The return URL receives ?appski_session=cs_…&status=paid.
status is one of open, paid or expired.
{ "app_user_id": "user_42",
"entitlements": { "pro": { "active": true, "product": "pro_monthly", "purchase": "pur_…",
"expires_at": "2026-11-29T…", "will_renew": true } } }
Only active entitlements are listed. expires_at is null for lifetime unlocks.
The customer with entitlements and full purchase history.
Refunds through Stripe and removes access right away. Subscriptions are canceled.
Stops renewal. Access continues until period_end.
The last 100 events, for when you'd rather poll than receive webhooks.
NFT passes
Turn on Sell as an NFT pass when you create a subscription or one-time product. Each sale then mints an ERC-721 pass on Base with an expiry date (ERC-5643). Whoever holds the pass has access, so buyers can gift or resell it and access moves with it.
- Card buyers: the pass is minted to the app.ski custody wallet and counts for that buyer right away. Move it to their own wallet with
POST /v1/passes/:id/claim, or from the dashboard. Renewals, refunds and cancellations update its expiry onchain. - Crypto buyers: the checkout page offers Pay with USDC. The buyer's wallet calls the contract, which mints the pass to them and splits the payment: your payout wallet gets the price minus the 3% fee. These passes don't auto-renew; anyone can renew one onchain with
renew(tokenId, periods). - Set your payout wallet in Settings before creating NFT products. Prices are in USD and paid in USDC.
Token gating: what the passes in this wallet unlock in your app, whether or not the wallet is linked to a user.
{ "address": "0x…", "message": "<exact text from the GET above>", "signature": "0x…" }
Links a wallet to a user after they sign the message (valid for 15 minutes). From then on, passes in that wallet count in /entitlements. A wallet links to one user at a time, so a pass can't be shared across accounts.
{ "address": "0x…" }. Transfers a custodial pass to that wallet and links it to the buyer. Secret key only: your server should confirm the signed-in user owns the pass.
Entitlements that come from a pass include "nft": { "token_id", "owner", "custodial" }. Pass metadata is public at /nft/:token_id, with lift-ticket artwork at /nft/:token_id.svg. Webhook events: pass.claimed, pass.transferred and pass.renewed.
Store
The app.ski Store is a public directory where people browse apps by category, search, and subscribe on the web. List your app under Store listing in the dashboard: a store URL, tagline, category and description, plus optional icon, App Store, Google Play and website links. It needs at least one active product.
Store buyers aren't signed in to your app, so they enter an email at checkout and the purchase belongs to the app user ID email:<address> (lowercase). Two ways to honor it in your app:
- If your users sign in with email, use
"email:" + email.lowercased()as theappUserID. Store and in-app web purchases then land on the same customer. - Or look the buyer up from your server with
GET /v1/customers/email:you@example.com(secret key) and grant access to the matching account. Thepurchase.completedwebhook carriescustomer.app_user_idandcustomer.email.
Buyer accounts. Store buyers can create an app.ski account at /account. Once they confirm their email, it lists everything bought with it across apps: subscriptions (with cancel renewal), NFT passes (move out of custody, sell on Resale), linked wallets and resale listings. Linking a wallet there also links it to that buyer in your app, so passes in it count in /entitlements.
Resale
People can sell the time left on an NFT pass in the Store's Resale section. The buyer pays USDC and gets the pass (and its access) in the same transaction. The price is split onchain: the seller gets 92%, you get a 5% royalty at your payout wallet, and app.ski keeps 3%.
- Only a pass in a wallet can be listed. Card buyers claim theirs first.
- A listed pass keeps working for the seller until it sells. Moving it any other way cancels the listing.
- The buyer sets a price ceiling, so a seller can't raise the price at the last moment. Expired passes can't be listed or bought.
- Webhooks:
pass.listedandpass.resold(withprice,seller,buyerand thesplit), plus the usualpass.transferred. - For the buyer to use the pass in your app, they link the wallet (
POST /v1/customers/:id/wallets) or you gate by wallet (GET /v1/wallets/:address/entitlements).
Webhooks
Events: purchase.completed (includes data.pass for NFT products), subscription.renewed, subscription.canceled, subscription.reactivated, subscription.payment_failed, subscription.expired, purchase.refunded, and test.ping. Each payload has data.purchase, data.customer.app_user_id and the customer's current data.entitlements.
{ "id": "evt_…", "type": "purchase.completed", "created_at": "…",
"data": { "purchase": { "id": "pur_…", "product": "coins_100", "type": "consumable", "amount": 199, … },
"customer": { "app_user_id": "user_42", "email": "…" },
"entitlements": { … } } }
Verify the signature (Node)
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Return 2xx quickly. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. Use the event id to ignore duplicates, and grant consumables exactly once per purchase.id.
Testing
On a server without Stripe keys, the checkout page shows a Pay (test mode) button, and purchases are recorded with livemode: false. From the dashboard's Integrate page you can open a checkout without writing any code. With Stripe test keys, use card 4242 4242 4242 4242.