Where payment logic must live

The single most important architectural decision: the mobile client never decides that a payment succeeded. It asks the server, and the server decides based on a provider-verified event.

A Flutter app can be paused, backgrounded, killed mid-request or running a stale build. Any of those can make a client-side "payment complete" signal untrue. Treat the client as a display of state the server owns.

Flutter app                 Laravel backend              Payment provider
    |                             |                             |
    |--- create order ----------->|                             |
    |<-- order id ----------------|                             |
    |--- request payment -------->|-- create intent ----------->|
    |<-- client secret -----------|<-- client secret -----------|
    |--- confirm payment ------------------------------------->|
    |                             |<-- webhook (authoritative) -|
    |<-- order status (poll) -----|-- update order -------------|

Create the payment on the server

Amounts, currency, line items and merchant identifiers are set server-side and never accepted from the client. Accepting an amount from the app lets anyone with a modified build choose what they pay.

The Flutter app receives only what it needs to complete the confirmation step.

public function createPaymentIntent(Order $order, Request $request)
{
    $this->authorize('pay', $order);

    $intent = Stripe::paymentIntents()->create([
        'amount' => $order->totalInMinorUnits(),
        'currency' => $order->currency,
        'metadata' => ['order_id' => $order->id],
        'idempotency_key' => 'order-'.$order->id.'-payment',
    ]);

    return response()->json([
        'data' => ['clientSecret' => $intent->client_secret],
    ]);
}
Idempotency keys matter. A user tapping "Pay" twice, or a flaky mobile connection retrying, must not create two charges.

Confirm in the client, then stop trusting it

The Flutter side uses the provider\u2019s SDK to collect card details and confirm the intent. Card fields should be hosted by the provider\u2019s SDK wherever possible so raw card data never touches your code or your logs.

After confirmation returns, show a pending state and move to an order status screen. Do not flip the order to paid at this point.

final result = await PaymentSheet.presentPaymentOptions();

if (result != null) {
  setState(() => status = PaymentStatus.processing);
  await api.refreshOrderStatus(orderId); // server-derived, not client-derived
}

Webhooks are the source of truth

Providers notify your backend asynchronously. That webhook, not the app, is what marks an order paid. Build the handler to be safe to run more than once, because providers retry until they receive a 2xx.

Verify the signature before parsing the payload. An unverified webhook endpoint lets anyone who finds the URL mark orders as paid.

public function handleWebhook(Request $request)
{
    $event = Webhook::constructEvent(
        $request->getContent(),
        $request->header('Stripe-Signature'),
        config('services.stripe.webhook_secret')
    );

    match ($event->type) {
        'payment_intent.succeeded' => $this->markPaid($event->data->object),
        'payment_intent.payment_failed' => $this->markFailed($event->data->object),
        'charge.refunded' => $this->markRefunded($event->data->object),
        default => null,
    };

    return response()->noContent();
}
  • Verify the signature on every request
  • Make the handler idempotent using the provider event ID
  • Return 2xx quickly and process heavy work on a queue
  • Log the event ID, not the full payload with card details

Idempotency end to end

Three separate layers need idempotency, and missing any one of them produces duplicate charges or duplicated fulfilment.

  • Client retry: the same order creates the same intent, not a new one
  • Server creation: an idempotency key is sent to the provider
  • Webhook processing: the provider event ID is stored so repeats are ignored

Refunds, partial captures and edge states

Design for the states that are not "success". A payment can be pending, requires action, fails, expires, is refunded partially, or is disputed. If the order model only has paid and unpaid, those states get crammed into one of them and reconciliation becomes guesswork.

Represent the provider status faithfully in the order model and map it to user-facing language separately.

Testing without charging real money

Use the provider test mode with test card numbers covering success, failure, 3D Secure challenge and slow authorisation. Exercise the full loop including the webhook, because the webhook path is where most integration bugs hide.

Add a feature test that replays a signed test webhook twice and asserts the order ends up paid exactly once.

What we check before enabling live payments

A short pre-launch list that has caught real issues repeatedly.

  • No secret keys in the Flutter binary or in the repository
  • Amounts originate from the server order, never the client
  • Webhook signature verification enabled with a real secret
  • Idempotency keys on intent creation and webhook processing
  • Failure and cancellation paths tested on a real device
  • Refund flow verified against a real (small) charge
  • Error messages shown to users are understandable, not raw API text