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:
For production purchases, replace the sandbox URL with:
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:
Generates a short-lived JWT signed with your In-App Purchase private key configured in the brainCloud portal
Calls GET /inApps/v1/transactions/{transactionId} on Apple's StoreKit Server API
Decodes and validates the returned signedTransactionInfo JWS token
Verifies that the bundleId, productId, and environment match what is configured for your brainCloud app
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.

