Docs
Receipt validation
Your users buy coins through Apple or Google in-app purchases. Before your server credits them, it can ask GiftingStack whether the purchase is real and whether it was already used. GiftingStack only reads the store's receipt: the payment itself stays between your user, the store and you. On your live environment a verified receipt is how a store sale is recorded for your service fee (How your fee is measured); in test it stays optional.
How it works
1. The app completes a purchase and sends the receipt to your server. 2. Your server calls POST /v1/receipts/verify with its secret key. 3. If valid is true and already_seen is false, your server credits the coins with the receipt_id it got back. 4. The app finishes (consumes) the purchase.
Verifying never changes a balance. You still call credit yourself; use the
store's transaction id as the credit's reference so a retry cannot credit
twice, and pass the receipt_id (see
Credit with the receipt).
1. Set up the stores
In the dashboard, open Purchases (under Build). On live you need it for every store you sell through.
- App Store: add your app's bundle ids. Nothing else is needed: Apple transactions are verified offline.
- Google Play: add the package name and upload a service account key (JSON). In Play Console, give that service account the View financial data permission for the app. The key is stored encrypted and never shown again; the dashboard shows only its email address. Only the project owner can upload or remove it, and only the owner can change the bundle ids and the package name.
-
Prices: Apple transactions carry their own price. For Google Play we read
the order with the Play Developer API (
orders.get), which needs the View financial data permission above; its total includes the quantity and is what your user paid. There is no price table to maintain. If Google does not return the order, the receipt is still valid but has no price (see When the price is missing).
2. Verify from your server
In the examples, $GS_KEY is your secret key and $API is
https://api.giftingstack.com (as set in the
Quickstart), and user is your own id for the
person (the one you use in credit), not a GiftingStack id.
# App Store: StoreKit 2 Transaction.jwsRepresentation (or signedTransactionInfo)
curl -X POST $API/v1/receipts/verify \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-d '{"platform": "apple", "signed_transaction": "eyJhbGciOiJFUzI1NiIs…", "user": "ava"}'
# Google Play: the purchase token and its product id
curl -X POST $API/v1/receipts/verify \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-d '{"platform": "google", "purchase_token": "…", "product_id": "coins_500", "user": "ava"}'
The answer, the same for both stores:
{
"valid": true,
"status": "purchased",
"receipt_id": "rcp_01JA…",
"platform": "apple",
"environment": "live",
"transaction_id": "2000000712345678",
"original_transaction_id": "2000000712345678",
"product_id": "coins_500",
"purchased_at": "2026-10-13T09:00:00.000Z",
"price": "4.99",
"currency": "USD",
"price_source": "store",
"price_missing": null,
"quantity": 1,
"already_seen": false,
"first_seen": null,
"google": null
}
-
validis true only for a paid purchase that was not refunded.statusisrevoked(refunded),pendingorcancelledotherwise. Those are not recorded, so a pending Google purchase can be verified again once it is paid. -
priceis a decimal string in major units, or null when unknown (older Apple transactions, Google products without a price, promo codes).price_sourceisstorewhen the price is the store's own (the Apple transaction, the Google order) and null when there is no price.price_missingthen says why (permission,not_foundorunavailable). -
useris optional. Only a hash of it is stored. On a later attempt,first_seen.same_usertells you whether it was for the same user.
Each purchase counts once
The first successful verification records the store's transaction id for your project and
environment. Every later verification of the same purchase, with any of your keys and
however many arrive at once, returns already_seen: true and the time of the
first one. Do not grant it again. The dashboard lists recent verifications and how many
duplicate attempts each one had.
Credit with the receipt
Pass the receipt_id to credit. GiftingStack then records the
store's price on the credit and makes sure the receipt credits once, however many requests
race:
curl -X POST $API/v1/users/ava/credit \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(node -e 'console.log(crypto.randomUUID())')" \
-d '{"amount": 500, "reason": "purchase", "reference": "appstore_2000000123",
"receipt_id": "rcp_01JA0Q8X2M4N6P8R0T2V4W6Y8Z"}'
# {"amount":500,"balance_after":500,"price":"4.99","currency":"USD",
# "price_source":"receipt","receipt_id":"rcp_01JA0Q8X2M4N6P8R0T2V4W6Y8Z",…}
-
The receipt must come from the same project and environment (a test key cannot use a
live receipt): otherwise
404 receipt_not_foundfrom the credit endpoint. -
A second credit with the same receipt, with any key or reference, is
409 receipt_already_credited(details.ledger_entry_idnames the first credit). -
If a later verification finds the purchase refunded or cancelled, the receipt can no
longer be used:
422 receipt_revoked. Coins already credited stay; book the refund yourself with adebit. -
Do not send
priceorcurrencywith a receipt: on live it is400 invalid_request, because the store's price is the one recorded. (In test they are accepted if they equal the receipt's, or the credit is422 receipt_price_mismatch.) -
If the request fails without an answer (timeout, 5xx), retry it with the same
Idempotency-Keyand body. The receipt stays reserved for exactly that request: the retry credits once or returns the first credit.
The price never changes the credited amount or any balance, and GiftingStack never charges, holds or pays out that money. On live it does set your service fee: a credit with a verified receipt is a store sale, and the store's price, minus refunds, is part of the fee base (How your fee is measured).
When the price is missing
If the store gave no price (an old Apple transaction, a promo code, or a Google order that
could not be read), price is null and, for Google, price_missing
says why: permission (the service account lacks View financial data),
not_found or unavailable. The receipt is still valid, but on live
a Google Play receipt without its price is not credited
(422 price_missing); a purchase with no price at all (a promo code) is
credited and not counted in your fee. The
Purchases page shows "Price not available: …". Fix the cause and verify the
purchase again: the price is filled in only while the receipt has not been credited, so
verify before you credit.
Store notifications
Apple and Google can also tell GiftingStack directly about every sale and refund: App Store Server Notifications (version 2) and Google Play Real-time Developer Notifications. This is optional, but on live it is how refunds reach your fee. Each project environment has its own secret notification URL, shown under Purchases in the dashboard. With it:
- the Usage page can show what the stores reported next to what your server sent;
-
refunds are subtracted, the matching verified receipt can no longer back a credit, and
you get a
purchase.refundedwebhook event, so you can decide what to do with the coins (for example adebitwithallow_negative). GiftingStack never takes coins back by itself.
This is information only. The money goes from the store to you; GiftingStack never receives, holds or moves it, and a notification never changes a balance.
App Store
- In App Store Connect, open your app, App Information, App Store Server Notifications.
- Paste the live URL into Production Server URL and the test URL into Sandbox Server URL; choose Version 2.
-
Keep your bundle id under Purchases. Send a test notification from App Store Connect:
it appears in the dashboard's recent notifications as
TEST.
Every notification is checked like a signed transaction (Apple Root CA - G3, Apple's
certificate markers, ES256, certificate validity), and so are the transaction and renewal
info inside it. The bundle id must be yours and the environment must match the URL: a
sandbox notification on the live URL is refused. Recorded:
ONE_TIME_CHARGE (consumables and other one-time purchases),
SUBSCRIBED and DID_RENEW as sales (not Family Sharing),
REFUND as a refund, REFUND_REVERSED adds the amount back. Other
types, CONSUMPTION_REQUEST included, are only logged.
Google Play
-
In the Google Cloud project linked to Play Console, create a Pub/Sub topic and give
google-play-developer-notifications@system.gserviceaccount.comthe Pub/Sub Publisher role on it. - In Play Console, Monetize with Play, Monetization setup: enter the topic under Real-time developer notifications.
- Create a push subscription on the topic with the live URL as endpoint and authentication enabled, using a service account of your Google Cloud project. Leave the audience empty: it defaults to the endpoint URL, which GiftingStack checks.
- Enter that service account's email under Purchases (project owner). Pushes signed by any other account are refused.
- Google sends real and license-tester purchases to the same topic. To record test purchases too, add a second push subscription with the test URL. Each URL records only its own environment and acknowledges the others.
Every push must carry Google's OIDC token: signed by Google, for this URL, from your
service account, not expired. Purchases are then confirmed with the Google Play Developer
API using the service account key you uploaded, and priced from the order (orders.get). Voided
purchases (refunds) are matched to a sale by order id; a partial refund is recorded
without an amount.
Each purchase counts once
Stores retry and sometimes send the same notification twice. Each delivery is recorded
once (Apple notificationUUID, Pub/Sub messageId), and each sale
or refund once per store transaction id (Google order id), however many notifications or
receipt verifications report it. A sale seen both in a notification and in
POST /v1/receipts/verify counts once.
The purchase.refunded event
{
"type": "purchase.refunded",
"data": {
"platform": "apple",
"transaction_id": "2000000712345678",
"original_transaction_id": "2000000712345678",
"product_id": "coins_500",
"price": "4.99",
"currency": "USD",
"refunded_at": "2026-10-20T08:15:00.000Z",
"receipt_id": "rcp_01JA…",
"credit_entry_id": "led_01JA…"
}
}
receipt_id and credit_entry_id are set when you verified the
purchase and credited it with that receipt; the ledger entry tells you which user got the
coins. Subscribe to the event under Webhooks (it is included when a webhook receives all
events).
Status and safety
- The dashboard shows, per environment, when each store last reached GiftingStack, the last refused notification and its reason, and the recent notifications.
- Keep the URL secret. If it leaks, the project owner can rotate it: the old URL stops working at once, for both stores. Paste the new one in App Store Connect and Pub/Sub.
- Notifications that are authentic but need nothing (other types, other environment, repeats) are answered with 200 so the store stops retrying. Refused ones get a 4xx reason, and the store retries them for a while.
- Only ids, product ids, prices, dates and notification types are stored; no card data, names or email addresses. Notification contents are never logged.
When a user says the coins did not arrive
Look the purchase up with your secret key, by the store's transaction id, the
receipt_id, your reference, a ledger entry id or the
request_id of a failed request:
curl "$API/v1/lookup?transaction_id=2000000123" \
-H "Authorization: Bearer $GS_KEY"
# {"receipts":[{"id":"rcp_…","verified_at":"…","credited_at":"…","credit_entry_id":"led_…",…}],
# "entries":[{"id":"led_…","amount":500,"balance_after":500,"reference":"appstore_2000000123",…}],
# "webhook_deliveries":[{"event_type":"balance.credited","status":"succeeded","last_status_code":200,…}],
# "rejected_requests":[],"has_more_rejected":false}
- No receipt: the purchase never reached GiftingStack. Your app or server did not verify it.
-
A receipt with
credited_atempty and no refused requests: it was verified, but your server never asked for the credit. -
Refused requests: your server asked and was refused. Each one has the error, the time,
the amount asked for and the user's balance at that moment. A
receipt_already_creditedorduplicate_referencemeans the coins went to the entry listed next to it. - An entry: the coins were credited, with the balance after. The webhook deliveries show when your endpoint was told and what it answered.
Every refused credit, debit, gift send and reversal is kept for 180 days (at most 5,000 a
day per project and environment), with what it asked for but never its metadata or gift
message. A request refused before it was read (a malformed body, a wrong key, a rate
limit) is not kept: its response says why. A user's refusals are listed by
GET /v1/users/{external_id}/rejected-requests. Ledger entries are kept
forever, webhook deliveries for 90 days. In the dashboard, the Lookup page shows the
same and copies it as text you can send to the user. Log the request_id of
every error response: it is in the error body and the X-Request-Id header.
Test and live
A test key accepts only sandbox purchases (Apple sandbox, Google license testers). A live
key accepts only real ones. A purchase from the wrong side is refused with
environment_mismatch, so a free sandbox purchase can never become live coins.
Xcode StoreKit test transactions are not signed by Apple and are refused.
What is checked
App Store (offline, no call to Apple):
- the JWS uses ES256 and carries three certificates;
- the chain ends at Apple Root CA - G3 (the only trusted root), each certificate is signed by the next, and the certificates carry Apple's App Store receipt-signing markers;
- the transaction signature verifies with the signing certificate;
- every certificate was valid when Apple signed the transaction, and the signing date is not in the future;
- the bundle id is one of yours and the environment matches your key.
Not checked: certificate revocation (OCSP). If Apple ever revoked a signing certificate, transactions signed with it would still verify until it expires.
Google Play (one call to the Google Play Developer API):
-
Google knows the token for your package name and product (otherwise
receipt_not_found); - the purchase state (purchased, pending or cancelled);
- test purchases match your key's environment. The response also shows whether the purchase was acknowledged or consumed.
Errors
-
422 receipt_invalid: not authentic.details.reasonis one ofmalformed,unsupported_algorithm,untrusted_root,certificate_chain_invalid,signature_invalid,certificate_expired,signed_in_future. 422 app_mismatch: another app's bundle id.422 environment_mismatch: sandbox on live or real on test.-
422 product_mismatch:product_iddiffers from the store's. -
422 receipt_not_found(from/v1/receipts/verify, not the same as the credit's 404 above): Google does not know this token here. -
422 store_credentials_rejected: Google refused your service account or its permissions. -
409 receipts_not_configured: set up the store in the dashboard first. 503 store_unavailable: Google did not answer. Retry later.-
429 rate_limited: more than 20 verifications per 10 seconds per project and environment.