DEV Community

Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

7 Stripe Integration Mistakes That Cost Real Money in Production (and the Fixes That Shipped)

Seven Stripe mistakes from a live production integration: storing your own order number as the capture reference, so every void fails with No such payment_intent; swallowing those failures until holds strand in requires_capture; masking card decline reasons behind a generic error; passing apple_pay or google_pay as payment_method_types; skipping the per-currency minimum guard and hitting amount_too_small; an env() default that an explicit null silently disables; and a statement descriptor customers do not recognise. Each section shows the bad code, why it fails, and the fix that shipped.

Every one of these passed test mode. Every one failed against real money on a high-volume platform I run payments for — one stranded a customer's card hold, another quietly guaranteed a completed order never charged the card. The examples use a manual-capture flow (authorize at checkout, capture on fulfilment, void on cancellation) on Laravel 12, but the failure modes are stack-agnostic. [INTERNAL LINK: contextual link to a related payments/Laravel post goes here]

Mistake #1: Storing your order number as the capture reference (No such payment_intent)

When the checkout webhook confirms authorization, you record the reference you will later capture or void with. We stored the wrong one:

// BAD: our own order number is NOT something Stripe recognises
$hold->update([
    'status'            => PaymentHoldStatus::Authorized,
    'capture_reference' => $session->merchant_order_id, // "ORD-1042"
]);

Every later call did $stripe->paymentIntents->capture('ORD-1042') and got No such payment_intent. The void failure was swallowed by a best-effort catch, the row was marked Released, and the customer's hold sat at Stripe with nothing left pointing at it. The capture path failed identically — so a fulfilled order could never charge the card, and the fallback logic quietly ate the loss.

If you support multiple providers the trap doubles, because each provider's void/capture API keys on a different identifier. Resolve it explicitly:

// GOOD: store the reference each provider's void()/capture() actually accepts
private function captureReferenceFor(CheckoutSession $session, ?string $webhookRef): ?string
{
    return match ($session->provider) {
        // Stripe: the PaymentIntent id (pi_...) — NOT your order number
        'stripe' => $session->provider_payment_id,

        // Some providers only reveal their transaction ref in the webhook
        // payload — pass it through from the webhook controller.
        'legacycard' => $webhookRef ?? $session->merchant_order_id,

        // Others (many BNPL providers) genuinely key on the order id
        // they issued you at checkout creation.
        default => $session->provider_order_id,
    };
}

Then pin each branch with a regression test, so the next refactor cannot blanket-swap the field and break the providers that were correct.

Mistake #2: Swallowing void failures until holds strand in requires_capture

The void failure above was caught, logged and ignored — deliberately, because releasing a hold must never block an order cancellation. That availability decision was right. What was missing is the second half of the contract:

// BAD in isolation: swallow, mark released, move on
try {
    $provider->void($hold->capture_reference);
} catch (\Throwable $e) {
    Log::error('Provider void failed during hold release', ['error' => $e->getMessage()]);
}
$hold->update(['status' => PaymentHoldStatus::Released]);

If you swallow a failure that leaves your database and your payment provider disagreeing, you owe yourself a reconciliation job that notices. This one is small enough to write today:

// GOOD: divergence gets found the same day, not at auth expiry
// app/Console/Commands/ReconcileStripeHolds.php
public function handle(StripeClient $stripe): int
{
    $stuck = collect($stripe->paymentIntents->all(['limit' => 100])->data)
        ->filter(fn ($pi) => $pi->status === 'requires_capture'
            && $pi->created < now()->subHour()->timestamp);

    foreach ($stuck as $pi) {
        $hold = PaymentHold::where('capture_reference', $pi->id)->first();

        // A hold Stripe still holds, that our DB thinks is finished,
        // is money sitting on a customer's card. Page a human.
        if (! $hold || $hold->status !== PaymentHoldStatus::Authorized) {
            Log::critical('Stripe hold diverged from local state', [
                'payment_intent' => $pi->id,
                'local_status'   => $hold?->status?->value ?? 'missing',
            ]);
        }
    }

    return self::SUCCESS;
}

Schedule it hourly. A stranded hold auto-expires in about seven days; your customer notices it in one.

Mistake #3: Masking real card decline reasons behind a generic error

Our first live decline — genuinely insufficient funds — reached the client as Server Error. The provider SDK threw, an unhandled exception became a 500, and the one message the user needed was destroyed on the way up.

// BAD: every failure becomes the same useless string
} catch (\Throwable $e) {
    throw new PaymentProviderException('stripe', $e->getMessage());
}

The distinction that matters: card errors are the user's business, everything else is yours. Stripe already separates them — CardException messages are written to be shown to cardholders. Carry that message in a dedicated field so the boundary survives to your response layer:

class PaymentProviderException extends RuntimeException
{
    public function __construct(
        public readonly string $provider,
        string $message,
        public readonly ?array $context = null,
        ?\Throwable $previous = null,
        public readonly ?string $customerMessage = null, // null = show generic
    ) {
        parent::__construct("[{$provider}] {$message}", 0, $previous);
    }
}

// In the provider call:
} catch (\Stripe\Exception\CardException $e) {
    throw new PaymentProviderException('stripe', $e->getMessage(), [], $e, customerMessage: $e->getMessage());
} catch (\Throwable $e) {
    throw new PaymentProviderException('stripe', $e->getMessage()); // customerMessage stays null
}

// In bootstrap/app.php (Laravel 11+ exception rendering):
$exceptions->render(fn (PaymentProviderException $e) => response()->json([
    'success' => false,
    'message' => $e->customerMessage ?? 'Something went wrong. Please try again later.',
], 503));

Declines now say "Your card has insufficient funds." Internal failures still say nothing exploitable.

Mistake #4: Passing apple_pay or google_pay as payment_method_types

When wallet buttons refused to appear in our mobile payment sheet, the tempting "fix" was server-side:

// BAD: this is an API error — no such enum values exist
'payment_method_types' => ['card', 'apple_pay', 'google_pay'],

Wallets are not payment method types. They ride the card rail: a wallet payment tokenizes into a card whose card.wallet.type reads apple_pay or google_pay. The server keeps ['card']; the buttons are pure client configuration — the wallet parameters of your SDK's payment sheet (flutter_stripe, stripe-react-native, native PKPaymentButton), which default to off and render nothing until set.

// GOOD: server stays exactly as it was
'payment_method_types' => ['card'],

And verify the real thing, not the button. Pull the first wallet payment and check the token actually decrypted:

$pi = $stripe->paymentIntents->retrieve($id, ['expand' => ['latest_charge']]);
$walletType = $pi->latest_charge->payment_method_details->card->wallet?->type;
// "apple_pay" / "google_pay" => the certificate and provider config really work

A rendered button only proves your client config parses.

Mistake #5: No guard for Stripe's minimum charge (amount_too_small)

Stripe rejects charges under a per-currency floor — 0.50 USD, for example. Full order totals rarely hit it. Delta charges do constantly: a small post-order adjustment, a top-up fee, a leftover balance. Ours surfaced through Mistake #3's generic message, leaving customers stuck on an unpayable balance with no explanation.

// config/payment.php
'stripe' => [
    'minimums' => ['USD' => 0.50, 'EUR' => 0.50], // per-currency floors you serve
],

// GOOD: refuse before the API call, with a message the user can act on
private function guardMinimumCharge(float $amount, string $currency): void
{
    $min = config('payment.stripe.minimums.'.strtoupper($currency));

    if ($min === null || $amount >= $min) {
        return; // fail OPEN on currencies you hold no figure for
    }

    throw new PaymentProviderException('stripe', "Amount below minimum {$min}", [],
        null, customerMessage: "The minimum card payment is {$min} {$currency}.");
}

Two deliberate choices: fail open for unknown currencies (wrongly blocking a valid payment is worse than letting Stripe reject one), and route the message through the same user-safe channel as card declines.

Mistake #6: An env() default that an explicit null silently disables

That minimum lives in config, which is where the sneakiest bug of the set waits:

// BAD: env()/config() defaults apply when the key is ABSENT — not when it's null
'minimum_usd' => env('STRIPE_MINIMUM_USD', 0.50),
// declared-but-unset env var => null => (float) null => 0.00 => guard silently OFF
// GOOD: ?: catches null and '' as well as absence
'minimum_usd' => (float) (env('STRIPE_MINIMUM_USD') ?: 0.50),

This shape has bitten us more than once, always silently, always through a stale config cache or a declared-but-empty variable. A guard that disables itself without logging is worse than no guard — you believe you are protected.

Mistake #7: A statement descriptor customers do not recognise

Our payment sheet showed the brand name customers know. The card statement showed the registered legal entity — a long, formal string that looks nothing like the product they used. That gap is a top generator of "I don't recognise this charge" disputes, and wallets amplify it because the sheet is the only branding in the whole flow.

Three things I learned fixing it: the customer-facing descriptor lives on the account's business details settings page — the Dashboard's search will happily route you to the payout descriptors instead, which change what appears on your own bank statement, not your customers'. Set the short brand name (5–22 characters, at least one letter). And keep it short deliberately: if you ever add a statement_descriptor_suffix per charge, Stripe truncates a long static descriptor to 10 characters as the prefix, mangling it.

// Verify from the API — the Dashboard has three fields with similar names
$account = $stripe->accounts->retrieve();
echo $account->settings->payments->statement_descriptor; // the customer-facing one

Conclusion: distrust your own database

Six of these seven mistakes share one root: treating your local record of a payment as the truth. The hold said Released while Stripe held the money. The config said guarded while the cast said 0.00. The button rendered while the wallet token had never decrypted. The pattern that fixed all of them was the same — verify against Stripe's API, not against your own tables: read back the PaymentIntent, check card.wallet.type, reconcile requires_capture hourly, pin every identifier with a regression test. [INTERNAL LINK: contextual link to a second related post goes here]

Next in this series

Part 2 covers the wallet rollout this post only brushed: Apple Pay and Google Pay in a mobile payment sheet — every gate that blocks the button. The certificate that isn't the one Apple's docs steer you to, the account-level toggle that mobile silently depends on, and the staged rollout behind a remote flag that can't strand test tokens in a live build. [LINK WHEN LIVE: /en/apple-pay-google-pay-payment-sheet-gates]

If a PaymentIntent is sitting in requires_capture in your account right now, don't wait for Part 2 — run the reconciliation command from Mistake #2 against your live keys today. It takes ten minutes and it has already paid for this entire series once.

I post each part natively on LinkedIn with the war story that didn't fit the post — follow me there to catch Part 2, or reply with the weirdest state your payment records have ever disagreed with Stripe about. I read all of them.

 


Originally published at dineshstack.com — read the full version with code samples and updates there.

Top comments (0)