Docs
Set up Vend16
Vend16 checks each in-app purchase with Apple or Google, keeps a record of what every user has paid for, and tells your server when anything changes. This guide gets you from nothing to live. Budget about 30 minutes.
Integrate Vend16 in-app purchase verification into this app. Read https://vend16.com/llms-full.txt first and follow it exactly. Use my public key pk_REPLACE_ME. Report every completed purchase to POST /v1/receipts with the store token and my user ID, only call finish() after Vend16 replies, and unlock features from GET /v1/subscribers/:appUserId, never from the device. If this is a Capacitor app, install the plugin with: npm install github:spruikco/capacitor-iap. Tell me which App Store Connect and Play Console settings I still need to change.
1. Quick start
- Create an account and confirm your email.
- Click Create app. Enter your iOS bundle ID (for example
com.company.app) and your Android package name. They can be the same. - Copy the two keys shown. You only see them once.
pk_…is the public key. It goes inside your app. It can only report purchases and read a user’s access, so it’s safe to ship.sk_…is the secret key. Keep it on your server only.
Your app’s page in the dashboard shows the exact store URLs you need for the next two steps.
2. Connect the App Store (5 minutes)
This lets Apple tell us about one-off purchases, renewals, cancellations and refunds, even when your app is closed.
- Open App Store Connect, choose your app, then App Information.
- Scroll to App Store Server Notifications.
- For Production Server URL and Sandbox Server URL, paste the Apple URL from your dashboard. It looks like
https://vend16.com/v1/notifications/apple/app_… - Choose Version 2 notifications and save.
Nothing else is needed for iOS. Apple signs every purchase, and we check that signature against Apple’s own certificates. No Apple API key required.
3. Connect Google Play (15 minutes)
Google doesn’t sign purchases the way Apple does, so we need permission to ask Google about them.
a. Create a service account
- In Google Cloud Console, pick or create a project and enable the Google Play Android Developer API.
- Go to IAM and Admin, Service Accounts, create one, then under Keys add a JSON key and download it.
b. Give it access in Play Console
- In Play Console, open Users and permissions and invite the service account’s email address.
- Give it View financial data and Manage orders and subscriptions for your app.
c. Upload the key
On your app’s page in Vend16, paste the JSON into Google Play service account and save. We encrypt it straight away.
d. Turn on Google’s notifications
- In Google Cloud, open Pub/Sub and create a topic, for example
play-notifications. - On that topic, add
google-play-developer-notifications@system.gserviceaccount.comas a Pub/Sub Publisher. - Create a Push subscription on the topic. Paste the Play URL from your dashboard as the endpoint. It includes a secret, so copy it whole.
- In Play Console, go to Monetise, Monetisation setup and set the topic under Real-time developer notifications. Click Send test notification. We accept it straight away but don’t list it in your dashboard, so check it arrived in the push subscription’s delivery metrics in Google Cloud.
4. Add it to your app (10 minutes)
The pattern is the same everywhere. After the store’s payment sheet closes:
- Send the purchase token to
POST /v1/receiptswith your user’s ID. - Only once we reply, tell the store the purchase is finished.
- Unlock features based on our reply, not the phone.
Capacitor
Install our open-source plugin and client helper.
npm install github:spruikco/capacitor-iap
npx cap sync
import { SpruikIap } from '@spruik/capacitor-iap';
import { createVend16 } from '@spruik/capacitor-iap/vend16';
const vend16 = createVend16({ apiKey: 'pk_your_public_key' });
// Listen BEFORE initialising: the stores replay unfinished purchases on connect.
await SpruikIap.addListener('transactionUpdated', (tx) => vend16.handle(tx, user.id, SpruikIap));
await SpruikIap.initialize({ productIds: ['coins_100', 'pro_monthly'] });
const { status, transaction } = await SpruikIap.purchase({ productId: 'pro_monthly' });
if (status === 'purchased') await vend16.handle(transaction, user.id, SpruikIap);
vend16.handle records the purchase with us and only then calls finish(). Get the helper from the plugin repo.
Any other platform
Send the token yourself. On iOS it’s the StoreKit 2 jwsRepresentation. On Android it’s the Play purchaseToken, plus the product ID.
POST https://vend16.com/v1/receipts
Authorization: Bearer pk_your_public_key
Content-Type: application/json
{ "platform": "android", "token": "<purchaseToken>", "productId": "coins_100",
"productType": "consumable", "appUserId": "user_42" }
productType is optional: consumable, non_consumable or subscription. Send it on Android, where Google won’t tell us the type; without it a one-off product is recorded as consumable. Send price too, for example "price": { "micros": 9990000, "currency": "AUD" }: Google doesn’t tell us that either.
If Apple or Google tells us about a one-off purchase before your app does, we record it straight away (for Apple, once your app’s iOS bundle ID is set in Setup, so we can check the purchase is yours). A Google Play one is recorded as unknown (we don’t guess) until your app’s report fills in the type and the price. If you gave the store an account ID with the purchase (appAccountToken on iOS, obfuscatedAccountId on Android), we file the purchase under that ID until your app reports it, then under your user’s ID. Apple’s account ID is a UUID, and we always use it in lower case, whatever case your app or Apple prints it in.
5. Check what a user has
GET https://vend16.com/v1/subscribers/user_42
Authorization: Bearer pk_your_public_key
{ "app_user_id": "user_42",
"active_product_ids": ["pro_monthly"],
"subscriptions": [{ "product_id": "pro_monthly", "status": "active", "is_active": true,
"expires_at": "2026-10-24T05:00:00Z", "will_renew": true, "platform": "ios" }],
"non_consumables": [], "recent_one_time_purchases": [] }
Use active_product_ids to decide what to unlock. A subscription that has been cancelled but is still paid up stays active until it expires.
6. Webhooks
Add a webhook URL on your app’s page and we send every event there as a JSON POST. Use Send test event to check it works.
Events: initial_purchase, renewal, non_subscription_purchase, cancellation, uncancellation, billing_issue, billing_recovered, paused, expiration, refund, revocation, refund_reversed.
{ "id": "evt_…", "type": "refund", "app_id": "app_…", "app_user_id": "user_42",
"environment": "Production", "created_at": "2026-09-24T05:00:00.000Z",
"data": { "platform": "ios", "product_id": "coins_100", "transaction_id": "…" } }
When Apple or Google tells us about a one-off purchase before your app reports it, we hold its non_subscription_purchase event for your app’s report: up to 72 hours for an App Store purchase (Apple hands an unfinished purchase back to your app every time it opens) and 10 minutes for a Google Play one. That way the event names your user and carries the type and price, just as if your app had reported first. If your app doesn’t report in that time, the event goes out within a few minutes anyway, naming the account ID you gave the store (or no user) and, for Google Play, possibly with product_type unknown. A later report still corrects our record, without a second event. Either way you get it once.
Check the signature
Every request carries Vend16-Signature: t=<unix time>,v1=<hex>. The hex is an HMAC-SHA256 of t + "." + raw body using your webhook secret.
import { createHmac, timingSafeEqual } 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 = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Reply with any 2xx. Otherwise we retry, backing off from one minute to six hours, for about three days.
7. API reference
All calls go to https://vend16.com with Authorization: Bearer <key>.
POST /v1/receipts(public or secret key): report and verify a purchase. Returnsstatus(recorded,duplicate,pendingorrevoked), the purchase, and the user’s current access.recordedmeans this is your app’s first report of the purchase, even if the store told us about it first (as long as we were still holding its webhook, see Webhooks);duplicatemeans your app reported it before.GET /v1/subscribers/:appUserId(public or secret key): what a user has.GET /v1/purchases?app_user_id=&limit=(secret key): raw purchase history.GET /v1/events?since=(secret key): events with webhook delivery status.DELETE /v1/subscribers/:appUserIdandDELETE /v1/purchases/:transactionId(secret key): delete a user’s data, or one purchase’s. See Delete a user’s data.
product_type in purchases and webhooks is consumable, non_consumable, subscription, non_renewing, or unknown for a Google Play one-off purchase we heard about from Google before your app reported it. A webhook only says unknown if your app didn’t report the purchase within 10 minutes.
8. Errors
Errors come back as { "error": { "code", "message" } }.
422 invalid_receipt: the store says this purchase isn’t real. Finish it with the store and don’t unlock anything.422 bundle_mismatch: the purchase belongs to a different app.502 store_unavailable: Apple or Google didn’t answer (we wait up to 15 seconds for Google). Don’t finish the purchase; the store will hand it back and you can try again.401and403: a missing or wrong key, or a public key used where the secret key is needed.
9. Rotate your keys
If a key leaks, click Rotate keys on your app’s Setup page. You get a new public key and a new secret key, shown once.
- The old secret key stops working straight away. Put the new one on your server as soon as you rotate.
- The old public key keeps working for 30 days, so copies of your app already on people’s phones keep reporting purchases while you ship an update with the new key. The Setup page shows how each key starts, when the old one ends and when it was last used. Once your update is out, click Stop it now to end it early.
10. Delete a user’s data
When one of your users asks you to delete their data, delete everything we hold about them with your secret key:
DELETE https://vend16.com/v1/subscribers/user_42
Authorization: Bearer sk_your_secret_key
Or just one purchase, by the store’s transaction ID (Apple) or order ID (Google Play). For a subscription, this takes every renewal with it. Add ?platform=ios or android to narrow it.
DELETE https://vend16.com/v1/purchases/2000000123456789
Authorization: Bearer sk_your_secret_key
{ "deleted": { "purchases": 2, "subscriptions": 1, "events": 3 } }
Add ?dry_run=1 to either to see what would go without deleting anything. You get { "dry_run": true, "would_delete": { … } } back. Because a delete can’t be undone, anything else we don’t expect (another value for dry_run, or a misspelt parameter such as dryRun) gets a 400 and deletes nothing. A user ID that is a UUID matches whatever its case, so an ID someone reads out to you in capitals still works.
We delete the purchases, the subscription records and the events, including webhooks not yet sent. We delete rather than anonymise, so the numbers in your dashboard stay consistent: those sales simply stop counting. Deleted live purchases still count towards the Free plan’s 100: we keep how many were deleted, and nothing about who or what. Deleting again is safe and returns zeros. Nightly backups roll off within 14 days. If the store or your app reports the same purchase again later, we record it again as new.
11. Plans and limits
Free: your first 100 live purchases, on 1 app. Pro: US$29 a month (A$49 in Australia), 10 apps, unlimited purchases. Test purchases never count. Passing the Free allowance never blocks your customers’ purchases; the dashboard locks until you upgrade. Manage billing from Billing in your dashboard.
Stuck? Email hello@spruik.co and a real person will help.