How do I use the Test Store to test purchases without a store account?
The Test Store is RevenueDot's built-in store for development. Create a Test Store app, give its test_ key to the SDK, and purchases go through the SDK's test dialog instead of Apple or Google. They unlock entitlements, record events and send webhooks like real purchases, and they are always sandbox. To see a whole lifecycle without waiting, simulate it with POST /v2/projects/{project_id}/test_purchases.
Set it up#
B="$REVENUEDOT_URL/v2/projects/$PROJECT_ID"; H="Authorization: Bearer $SECRET_KEY"
curl -s -X POST "$B/apps" -H "$H" -H "Content-Type: application/json" -d '{"name":"Test Store","type":"test_store"}'
curl -s "$B/apps/$APP_ID/public_api_keys" -H "$H" # -> "key": "test_..."
curl -s -X POST "$B/products" -H "$H" -H "Content-Type: application/json" \
-d '{"store_identifier":"pro_monthly","app_id":"'$APP_ID'","type":"subscription","display_name":"Pro monthly","subscription":{"duration":"P1M"}}'Attach the products to your entitlement and offering like any other app's products. The seed script does all of this in one run; see the Quickstart.
- The product's
subscription.durationis the period. AP1Mproduct expires one month after purchase. Without a duration, one month is used. - The price is the product's Test Store price. Set it in the dashboard (New product and the app page ask for it; Edit product changes it) or with
"test_store_price":{"amount_micros":9990000,"currency":"USD"}in the create or update call. A product without one shows 0. The price the SDK posts with the purchase is recorded. Use an ISO 4217 code that has an exchange rate to USD (every App Store and Google Play currency does); other codes are refused, because their purchases would count as no revenue.
Prices in several currencies#
A Test Store product can have one price per currency. One of them is the default: it is what indicative_price shows and what a customer sees when their currency has no price.
- Dashboard. Edit product lists one row per currency. Add currency adds a row, the Default radio picks the default, and the × button removes a currency. The product page lists every price, the default first.
- API. The same endpoints as RevenueCat, with the bodies its CLI sends:
# Add prices (an existing currency gets the new amount)
curl -s -X POST "$B/products/$PRODUCT_ID/test_store_prices" -H "$H" -H "Content-Type: application/json" \
-d '{"prices":[{"currency":"EUR","amount_micros":8990000},{"currency":"GBP","amount_micros":7990000}]}'
# List them: a JSON array of {"id","currency","amount_micros"}, the default first
curl -s "$B/products/$PRODUCT_ID/prices" -H "$H"
# Change one
curl -s -X PATCH "$B/products/$PRODUCT_ID/prices/EUR" -H "$H" -H "Content-Type: application/json" -d '{"amount_micros":9490000}'
# Remove one (RevenueDot extension)
curl -s -X DELETE "$B/products/$PRODUCT_ID/prices/GBP" -H "$H"test_store_price on product create and update sets the default price and keeps the other currencies; null clears every price. See List a product's prices.
- Which price the SDK shows. The price in the currency purchases-js asks for; else in the currency of the store country the SDK sends (the App Store storefront on iOS, the Play country on Android); else in the currency of the country the customer was last seen in; else the default. A customer in Germany sees the EUR price, a customer in Japan sees the default when there is no JPY price.
- What a purchase records. The native SDKs post the price they showed, and it is recorded as sent. purchases-js posts only the currency, so the product's price in that currency is recorded. A simulated purchase without
priceuses the price incurrency(or incountry_code's currency), else the default.
Buy in the app#
Configure the SDK with the test_ key and your server as the proxy URL, then call purchase as usual. The SDK shows its Test Store dialog; choose the successful purchase. The SDK posts fetch_token = test_<purchase time in ms>_<id> to POST /v1/receipts, and RevenueDot accepts any token of that form.
The Test Store works with purchases-js, React Native (including Expo Go and the web) and the native SDKs. Use test_ keys only in development builds; ship your store keys (appl_, goog_) in releases. Servers built before 2026-09-30 sent product details the native iOS SDK could not read ("No base price found for product"); update the server if you see that.
Test your setup with the sample app#
The app page has Test your setup with the sample app: pick iOS (SwiftUI), Android (Jetpack Compose), Flutter, React Native (Expo) or web, and you get a zip of that app from revenuedot/examples with this app's key, your server's URL and your first entitlement already filled in. Unzip it, follow its README, buy in a debug build, and the purchase shows up under Customers. App Store, Google Play and Web Billing apps offer the samples that can buy with their key. The same zip comes from GET /v2/projects/{project_id}/apps/{app_id}/sample_app?platform=ios. Only public values go in the zip.
Buy with curl#
curl -s "$REVENUEDOT_URL/v1/receipts" -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \
-d "{\"app_user_id\":\"user_1\",\"fetch_token\":\"test_$(date +%s)000_demo\",\"product_id\":\"pro_monthly\",\"price\":9.99,\"currency\":\"USD\"}"Simulate a lifecycle#
POST /v2/projects/{project_id}/test_purchases runs a whole history through the same pipeline as receipts, with each state applied at the time it would have happened. Events, revenue and webhooks come out as they would for a real subscription.
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/test_purchases" \
-H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \
-d '{"app_user_id":"user_renewal","product_id":"pro_monthly","scenario":"renewal","price":9.99}'{"object":"test_purchase","scenario":"renewal","store_transaction_id":"test_1790800923978_290d7238-2657-4c28-b829-520e19bc74ec","event_types":["INITIAL_PURCHASE","RENEWAL"],"customer":{"object":"customer","id":"user_renewal","...":"..."},"subscription":{"object":"subscription","status":"active","...":"..."},"purchase":null}scenario |
What happens | Default start | Events (monthly product) |
|---|---|---|---|
purchase |
Bought at the start | now | INITIAL_PURCHASE |
trial |
A 7-day free trial starts | now | INITIAL_PURCHASE (trial, price 0) |
trial_conversion |
Trial, then paid periods until now | 7 days ago | INITIAL_PURCHASE, RENEWAL (is_trial_conversion) |
renewal |
Bought, then renewed every period until now | one period ago | INITIAL_PURCHASE, RENEWAL |
cancel |
Like renewal, then auto-renew turned off now |
now | INITIAL_PURCHASE, CANCELLATION |
billing_issue |
The charge fails at the last period end; 7 days of grace | one period ago | INITIAL_PURCHASE, BILLING_ISSUE, CANCELLATION |
refund |
Like renewal, then the latest period is refunded now |
now | INITIAL_PURCHASE, CANCELLATION (refund) |
expire |
Auto-renew off from the start; access ends at the period end or now | one period ago | INITIAL_PURCHASE, CANCELLATION, EXPIRATION |
offset_days(0 to 730) moves the start into the past, for example"offset_days": 95withrenewalon a monthly product gives three renewals. Or sendpurchased_atin epoch milliseconds.product_idis the product's id or store identifier; it must belong to a Test Store app (app_idpicks one when the project has several).- One-time products support
purchaseandrefundonly. price,currency(default USD) andcountry_codeare recorded like a real purchase. Withoutprice, the product's price incurrency(or incountry_code's currency) is used, else its default price.