GiftingStack
Sign in Get started

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.

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
}

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 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:

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

  1. In App Store Connect, open your app, App Information, App Store Server Notifications.
  2. Paste the live URL into Production Server URL and the test URL into Sandbox Server URL; choose Version 2.
  3. 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

  1. In the Google Cloud project linked to Play Console, create a Pub/Sub topic and give google-play-developer-notifications@system.gserviceaccount.com the Pub/Sub Publisher role on it.
  2. In Play Console, Monetize with Play, Monetization setup: enter the topic under Real-time developer notifications.
  3. 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.
  4. Enter that service account's email under Purchases (project owner). Pushes signed by any other account are refused.
  5. 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

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}

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):

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):

Errors