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:

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:

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:

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:

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.