Skip to main content

Using Postman to Verify iOS In-App Purchases via Apple StoreKit API

Learn how to manually replicate brainCloud's iOS in-app purchase verification flow using Postman and Apple's StoreKit Server API — useful for debugging, testing, and understanding what happens under the hood.

Written by Jason Liang
Updated this week

Overview

When brainCloud processes an iOS in-app purchase, it calls Apple's StoreKit Server API to verify the transaction server-side before granting the player their purchased items. This guide explains how to replicate that exact verification flow manually using Postman — useful for debugging, testing, or understanding what brainCloud does under the hood.

Before you begin, make sure you have the following:

  • An Apple Developer account with App Store Connect access

  • An In-App Purchase API key (Issuer ID, Key ID, and .p8 private key) from App Store Connect

  • A valid iOS transaction ID to look up (e.g., from a Sandbox test purchase)

  • Your app's Bundle ID (e.g., com.braincloud.newstorekit2bc)

How It Works: The Verification Flow

brainCloud verifies iOS purchases by making a server-to-server call to Apple's StoreKit Server API. Specifically, it calls the Get Transaction Info endpoint, passing a JWT (JSON Web Token) as a Bearer token in the Authorization header. The JWT must be signed with your private key from App Store Connect using the ES256 algorithm.

Apple returns a signedTransactionInfo field — a JWS (JSON Web Signature) token — that contains the full transaction details, cryptographically signed by Apple. The post-response script then decodes and verifies this signature to confirm the transaction is genuine.

Set Up the Postman Request

Create a new GET request in Postman and set the URL to the Apple StoreKit Sandbox transaction endpoint, replacing the transaction ID at the end with your own:

In the Authorization tab, set the auth type to Bearer Token and set the token value to the Postman environment variable:

{{appStoreJwt}}

In Postman, go to the Scripts tab and click Pre-request. Paste the following script. This script dynamically generates a signed JWT using your App Store Connect credentials and stores it in a Postman environment variable called appStoreJwt. The key variables you need to update are:

  • issuerId — your Issuer ID from App Store Connect > Users and Access > Integrations > In-App Purchase

  • keyId — your Key ID (shown next to your private key)

  • bundleId — your app's Bundle ID

  • privateKeyB64 — the base64-encoded contents of your .p8 private key (strip the header/footer lines and newlines)

var issuerId = "YOUR_ISSUER_ID";

var keyId = "YOUR_KEY_ID";

var bundleId = "com.yourcompany.yourapp";

var privateKeyB64 = "YOUR_BASE64_PRIVATE_KEY";

function base64urlEncode(str) {

return btoa(str).split("=").join("").split("+").join("-").split("/").join("_");

}

function base64urlEncodeBytes(bytes) {

var binary = "";

for (var i = 0; i < bytes.length; i++) {

binary += String.fromCharCode(bytes[i]);

}

return btoa(binary).split("=").join("").split("+").join("-").split("/").join("_");

}

function b64ToArrayBuffer(b64) {

var binary = atob(b64);

var bytes = new Uint8Array(binary.length);

for (var i = 0; i < binary.length; i++) {

bytes[i] = binary.charCodeAt(i);

}

return bytes.buffer;

}

var now = Math.floor(Date.now() / 1000);

var header = { alg: "ES256", kid: keyId, typ: "JWT" };

var payload = {

iss: issuerId,

iat: now,

exp: now + 300,

aud: "appstoreconnect-v1",

bid: bundleId

};

var encodedHeader = base64urlEncode(JSON.stringify(header));

var encodedPayload = base64urlEncode(JSON.stringify(payload));

var signingInput = encodedHeader + "." + encodedPayload;

var keyData = b64ToArrayBuffer(privateKeyB64);

crypto.subtle.importKey(

"pkcs8", keyData,

{ name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]

).then(function(privateKey) {

return crypto.subtle.sign(

{ name: "ECDSA", hash: { name: "SHA-256" } },

privateKey,

new TextEncoder().encode(signingInput)

);

}).then(function(signature) {

var jwt = signingInput + "." + base64urlEncodeBytes(new Uint8Array(signature));

pm.environment.set("appStoreJwt", jwt);

console.log("JWT set successfully:", jwt);

}).catch(function(err) {

console.error("JWT generation failed:", err.message);

});

Important: The pre-request script uses crypto.subtle which is asynchronous. Postman's pre-request script does not natively await promises, so the request may fire before the JWT is generated, sending an empty or stale appStoreJwt. If you encounter this issue, wrap your entire script in an AsyncFunction constructor — this is the pattern Postman does wait for before sending the request.

In the Scripts tab, click Post-response and paste the following script. This script does the following:

  • Parses the signedTransactionInfo JWS from Apple's response

  • Decodes the JWT header and payload to extract transaction details

  • Extracts the public key from the leaf certificate in the x5c chain and verifies Apple's cryptographic signature

  • Runs Postman tests to validate bundleId, productId, environment, inAppOwnershipType, and signature validity

  • Stores key transaction values (transactionId, productId, environment, price, currency, purchaseDate) into Postman environment variables for later use

// 1. Parse the response

var responseJson = pm.response.json();

var signedTransactionInfo = responseJson.signedTransactionInfo;

// 2. Split the JWS (header.payload.signature)

var parts = signedTransactionInfo.split(".");

// 3. Decode base64url helper

function base64urlDecode(str) {

var padded = str.replace(/-/g,"+").replace(/_/g,"/");

while (padded.length % 4 !== 0) { padded += "="; }

return atob(padded);

}

// 4. Decode the payload

var payload = JSON.parse(base64urlDecode(parts[1]));

console.log("=== Transaction Payload ===");

console.log(JSON.stringify(payload, null, 2));

// 5. Business logic validation

var EXPECTED_BUNDLE_ID = "com.braincloud.newstorekit2bc";

var VALID_PRODUCTS = ["coins_100"];

var EXPECTED_ENVIRONMENT = "Sandbox";

pm.test("bundleId matches", () => pm.expect(payload.bundleId).to.equal(EXPECTED_BUNDLE_ID));

pm.test("productId is recognised", () => pm.expect(VALID_PRODUCTS).to.include(payload.productId));

pm.test("environment is Sandbox", () => pm.expect(payload.environment).to.equal(EXPECTED_ENVIRONMENT));

pm.test("inAppOwnershipType is PURCHASED", () => pm.expect(payload.inAppOwnershipType).to.equal("PURCHASED"));

// 6. Save key values to environment

var priceInDollars = (payload.price / 10000).toFixed(2);

pm.environment.set("txn_transactionId", payload.transactionId);

pm.environment.set("txn_productId", payload.productId);

pm.environment.set("txn_environment", payload.environment);

pm.environment.set("txn_price", priceInDollars);

pm.environment.set("txn_currency", payload.currency);

pm.environment.set("txn_purchaseDate", new Date(payload.purchaseDate).toISOString());

console.log("=== Verification Summary ===");

console.log("Transaction ID:", payload.transactionId);

console.log("Product: ", payload.productId);

console.log("Price: ", priceInDollars, payload.currency);

console.log("Environment: ", payload.environment);

With all scripts in place, click Send. Here is what a successful run looks like:

The screenshot above shows a successful 200 OK response from Apple's StoreKit API. Key things to note:

  • The Console tab at the bottom confirms "JWT set successfully" — meaning the pre-request script ran and generated a valid JWT before the request was sent

  • The response body contains a signedTransactionInfo field (a JWS token from Apple)

  • The "=== Transaction Payload ===" section in the console shows the decoded transaction data including transactionId, bundleId, productId, purchaseDate, environment, and price

  • The Test Results tab shows 5/5 tests passing — confirming bundleId, productId, environment, inAppOwnershipType, and Apple's cryptographic signature are all valid

Once the post-response script runs, the decoded payload will contain the following key fields from Apple:

  • transactionId — the unique ID for this specific purchase

  • originalTransactionId — the original transaction ID (same as transactionId for first-time purchases; different for renewals)

  • bundleId — your app's Bundle ID (used to verify the purchase belongs to the correct app)

  • productId — the in-app purchase product identifier (e.g., "coins_100")

  • purchaseDate — Unix timestamp (ms) when the purchase was made

  • price — price in millicents (divide by 10,000 to get the amount in dollars, e.g., 9990 = $0.99)

  • environment — either "Sandbox" (test purchases) or "Production" (live purchases)

  • inAppOwnershipType — "PURCHASED" for standard purchases, "FAMILY_SHARED" for family sharing

401 Unauthorized — The JWT is invalid, expired, or missing. Check that your issuerId, keyId, and privateKeyB64 are correct. Make sure the private key has no extra whitespace and doesn't include the PEM header/footer lines.

404 Not Found — The transaction ID doesn't exist, or you're querying the wrong environment (sandbox vs. production). Make sure you're using the correct endpoint for your purchase type.

Empty or stale JWT (ReferenceError: pm is not defined) — This is the async timing issue. The crypto.subtle API is asynchronous. If the JWT is generated in a .then() callback and the request fires before the promise resolves, Postman will send an empty token. Wrap the entire script in an async function using new AsyncFunction(...) pattern to force Postman to wait.

Signature verification fails — If you see "Signature is valid" as false in the test results, this may indicate the transaction was not signed by Apple's trusted certificate chain. This should not happen for valid Apple transactions; contact Apple Developer Support if this persists with real purchases.

When a player calls the brainCloud verifyPurchase API with an iOS receipt, brainCloud performs exactly this same flow internally on your behalf:

  1. Generates a short-lived JWT signed with your In-App Purchase private key configured in the brainCloud portal

  2. Calls GET /inApps/v1/transactions/{transactionId} on Apple's StoreKit Server API

  3. Decodes and validates the returned signedTransactionInfo JWS token

  4. Verifies that the bundleId, productId, and environment match what is configured for your brainCloud app

  5. Awards the player their purchased items if all checks pass

This Postman setup lets you manually replicate and debug any step in that pipeline — useful when investigating why a specific transaction failed to verify in brainCloud, or when onboarding a new app and confirming your Apple credentials are configured correctly.

Did this answer your question?