Quick answer: Collect the store transaction ID in every failed-purchase bug report, verify the purchase against platform receipts and your own fulfillment log, and use a one-click admin tool to grant missing entitlements. Prevent most failures with a receipt-first architecture where every app launch checks for outstanding unfulfilled receipts. Treat these bugs as P0 — a player who lost $5 and got silence from you is a player who’s leaving a 1-star review by dinnertime.
“I bought the gem pack, got charged, but I never got my gems.” This report is bug tracking on hard mode. The player is angry, there’s real money involved, and every minute they wait for resolution feels like theft. Meanwhile, your team has to figure out whether the player is telling the truth, whether they really were charged, whether fulfillment actually failed or just hasn’t synced yet, and how to grant the item if needed. Here’s a workflow that doesn’t drown your support team.
Why These Reports Are Different
Most bug reports are complaints about an experience. Failed microtransaction reports are complaints about a financial transaction. The stakes are higher for a few reasons:
- Real money is involved: Players treat this with the same urgency as being charged twice at a restaurant.
- Platform rules require resolution: Apple, Google, and Steam all have policies requiring developers to resolve IAP disputes within specific timeframes.
- Refunds hurt your metrics: A player who can’t reach you will refund through the store, and store refunds hurt your account standing.
- Public anger spreads fast: A player who feels scammed posts to Reddit, Twitter, the store reviews, and your Discord. All within an hour.
Collecting the Right Info Upfront
The single most important improvement to your bug report form is a dedicated field for transaction IDs. When a player reports a purchase issue, you need:
- Transaction / order ID from the store
- Platform (App Store, Play Store, Steam, Epic, direct)
- Date and approximate time of purchase
- Product they were trying to buy
- Amount charged
- Player’s in-game user ID (not email)
- Screenshot of the bank charge or store receipt if they have one
Make it explicit in the form: “Include your transaction ID so we can look up your purchase.” Players often don’t know what you mean by “transaction ID,” so include a short explanation linking to where to find it on each platform.
Verifying the Purchase
Every platform provides a server-side receipt verification API:
- Apple App Store:
verifyReceipt(legacy) or App Store Server API (current) - Google Play:
purchases.products.getandpurchases.subscriptions.get - Steam:
ISteamMicroTxn/QueryTxn
Use these to verify the transaction ID the player provided. You want to confirm three things: was the purchase completed on the platform, what was purchased, and has it been consumed / fulfilled from your backend’s perspective.
// Backend verification pseudocode
async function verifyAndFulfill(playerId, transactionId, platform) {
// Step 1: Check if we already fulfilled this transaction
const existing = await db.fulfillments.findOne({ transactionId });
if (existing) {
return {
status: 'already_fulfilled',
fulfilledAt: existing.createdAt,
product: existing.product
};
}
// Step 2: Verify with the platform
const receipt = await verifyReceipt(transactionId, platform);
if (!receipt.valid) {
return { status: 'invalid_receipt', reason: receipt.error };
}
if (receipt.status !== 'completed') {
return { status: 'not_completed', platformStatus: receipt.status };
}
// Step 3: Grant the entitlement
await db.transaction(async (tx) => {
await grantEntitlement(tx, playerId, receipt.productId);
await tx.fulfillments.insert({
transactionId,
playerId,
platform,
product: receipt.productId,
amountCents: receipt.amountCents,
createdAt: new Date()
});
});
// Step 4: Notify the player
await notifyPlayerFulfillment(playerId, receipt.productId);
return { status: 'fulfilled', product: receipt.productId };
}
Build a Support Admin Tool
Your support team needs a single-click interface for handling these cases. Hand-crafted database queries are too slow and too error-prone. Build a simple internal tool with:
- Lookup by transaction ID or player ID
- Display of transaction status (paid/fulfilled/both/neither)
- One button to verify and grant if paid but not fulfilled
- Audit log of who granted what
- Template replies to send the player in various languages
The tool should be the only way to grant entitlements outside of the normal fulfillment path. Direct database writes bypass audit logs and create compliance issues. Funnel everything through the admin tool.
Prevent Failures With a Receipt-First Architecture
The best way to reduce the volume of these reports is to prevent failures in the first place. The pattern to aim for is “receipt-first”: treat the platform receipt as the source of truth for what the player owns, and make your fulfillment logic idempotent.
On every app launch and every successful login, query the store for all outstanding non-consumed receipts and run them through your fulfillment pipeline. If a receipt has already been fulfilled, your backend returns “already fulfilled” and nothing happens. If not, the entitlement is granted. This heals crashes and disconnects automatically:
// Unity IAP example: check for unfulfilled purchases on launch
public class IAPRecoveryHandler : IStoreListener
{
private IStoreController storeController;
public void OnInitialized(IStoreController controller, IExtensionProvider ext)
{
storeController = controller;
// Check all owned products for fulfillment
foreach (var product in controller.products.all)
{
if (product.hasReceipt)
{
// Re-send to backend - it will be idempotent
SendReceiptToBackend(product);
}
}
}
void SendReceiptToBackend(Product product)
{
StartCoroutine(PostReceipt(product));
}
IEnumerator PostReceipt(Product product)
{
var body = new {
transactionId = product.transactionID,
receipt = product.receipt,
productId = product.definition.id
};
var req = UnityWebRequest.Post("https://api.yourgame.com/iap/fulfill",
JsonUtility.ToJson(body),
"application/json");
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success)
{
// Mark as consumed only after backend confirms fulfillment
storeController.ConfirmPendingPurchase(product);
}
}
}
The critical detail is calling ConfirmPendingPurchase only after your backend has confirmed the fulfillment. Until then, the receipt stays in the store’s pending list and will be retried on the next app launch. This is what self-heals mid-purchase crashes.
Response Templates That Don’t Sound Robotic
When you reply to a player whose purchase failed, don’t send a generic “thanks for reporting, we’ll look into it.” They want their stuff. Use a template like:
“Hi [Player], thanks for letting us know. I’ve checked transaction [ID] and confirmed the purchase went through but wasn’t delivered to your account — that’s on us, sorry about that. I’ve granted the [product] to your account now. Please restart the game and you should see it in your inventory. If it’s still not there in a few minutes, reply to this message and I’ll look into it personally.”
Short, specific, accountable, and actionable. Players who get this kind of response often leave positive reviews mentioning the quick support, which makes up for the refund headache.
What to Do When the Player Wasn’t Actually Charged
Sometimes the player is mistaken — their purchase was declined by their bank, they see a pending authorization on their statement that will drop, or they’re on a different account than the one they bought with. Handle this gently: explain what you found, point them to the store’s support for refunds of the authorization if needed, and don’t imply they’re lying. They’re confused, not malicious.
Related Issues
For Steam-specific billing issues, see How to Set Up Bug Reporting for Steam Games. For handling player complaints in general, see How to Handle Player Reports of Unfair Gameplay. For preventing save file losses which have similar stakes, check How to Prevent Save File Corruption Bugs in Your Game.
When real money is involved, respond fast and fix it yourself. Every minute is a customer you’re losing.