DEV Community

Cover image for Handling Payment Webhooks in Flutter the Right Way
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Handling Payment Webhooks in Flutter the Right Way

The one architecture mistake that costs Indian Flutter developers real chargebacks — and the server-plus-app pattern that fixes it.

So, in this article, I will be showing you how you can handle payment webhooks in a Flutter app the right way.

A client of mine — a subscription fintech in Gurugram — learned this the expensive way last year. Their backend marked orders paid directly from the payment SDK's callback inside the Flutter app, because wiring up webhooks felt like overhead. It worked until it did not: a customer's UPI payment settled while their phone was offline, the app never got the callback, the order stayed "pending," and the customer was charged for a subscription they could not use. By the time they added a real webhook pipeline, they had eaten a month of reconciliation and a few chargebacks. The fix took two days. The pattern in this article is that fix, so you do not need the scar.

First, the misconception that causes most of the damage. Webhooks do not fire inside your Flutter app. A webhook is an HTTP request that a payment gateway (Razorpay, Cashfree, PayU, Stripe, PayPal) sends to a server URL when something happens — a payment succeeded, a refund was initiated, a UPI mandate was revoked. Your mobile app cannot receive a webhook, because your phone has no public URL. So the question is not "how do I catch a webhook in Flutter" but "how does my Flutter app learn about payment events the right way."

The right architecture is three layers:

Gateway ──▶ Your Backend (verify + store)
                    │
                    └──▶ Flutter App (via push / WebSocket / poll)
Enter fullscreen mode Exit fullscreen mode

The backend receives the webhook, verifies the signature, stores the event, and then notifies the app. The app never trusts a gateway response from its own UI — it listens to the backend. That separation is the entire article, but let me show you the code so it sticks.

For this purpose, we need these dependencies in your Flutter app:

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0
  web_socket_channel: ^3.0.0
Enter fullscreen mode Exit fullscreen mode
  • http will be used by the app to fetch order status from your backend.
  • web_socket_channel will be used to listen for real-time payment events pushed from your backend while the app is open.

Let's jump into the coding part.

Step 1: The Backend Webhook Endpoint (Node.js)

This is where the money state actually lives. Your endpoint verifies the gateway's signature before touching anything else:

// POST /api/webhooks/payment
const crypto = require("crypto");

app.post("/api/webhooks/payment", (req, res) => {
  // 1. Verify signature before ANYTHING else
  const signature = req.headers["x-razorpay-signature"];
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))
    .digest("hex");

  if (signature !== expected) {
    return res.status(401).json({ error: "invalid signature" });
  }

  // 2. Idempotency: ignore duplicate deliveries
  if (await eventExists(req.body.id)) {
    return res.status(200).json({ received: true }); // already handled
  }

  // 3. Process and store
  await storeEvent(req.body);
  await updateOrderPayment(req.body.payload.payment.entity);

  // 4. Notify the app (see Step 3)
  notifyApp({ orderId, status: "paid" });

  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Three rules are non-negotiable here: verify the signature before processing, make the handler idempotent (gateways re-deliver webhooks when you are slow or down), and store every event. If you skip any one, you will process a duplicate charge or accept a forged event.

Step 2: The Flutter Client — Poll or Push

Inside Flutter, you have two reasonable ways to learn about the event, and one way you should never use.

The wrong way: trusting the payment SDK's success callback. It can lie — the user may kill the app, the network may drop, the gateway may settle asynchronously. Money state from a client callback is a chargeback factory.

The right way, Option A — WebSocket (app in foreground):

import 'package:web_socket_channel/web_socket_channel.dart';

class PaymentListener {
  WebSocketChannel? _channel;

  void start(String userId) {
    _channel = WebSocketChannel.connect(
      Uri.parse('wss://your-api.com/ws/payments?user=$userId'),
    );
    _channel!.stream.listen((event) {
      final data = jsonDecode(event as String);
      if (data['status'] == 'paid') {
        // update your local order state, show confirmation
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The backend pushes a message to the user's WebSocket channel the moment it processes the payment webhook. The app updates its UI. Simple, real-time, and it only works while the app is in the foreground — which is fine, because you pair it with Option B.

The right way, Option B — polling (app reopened):

Future<Map<String, dynamic>> fetchOrderStatus(String orderId) async {
  final res = await http.get(
    Uri.parse('https://your-api.com/api/orders/$orderId'),
  );
  return jsonDecode(res.body) as Map<String, dynamic>;
  // { status: "paid" | "pending" | "failed", ... }
}
Enter fullscreen mode Exit fullscreen mode

When the app opens or resumes, it fetches the order status from the backend. This catches everything the WebSocket missed while the app was closed. Between the WebSocket for the live moment and the poll for the re-open moment, the user always lands on correct state.

Step 3: How the Backend Notifies the App

The simplest robust version of notifyApp is a WebSocket hub: keep a map of connected user IDs to their channels, and publish to the relevant ones. For background notifications — the user's phone is locked and you want to tell them the payment succeeded — you add push via FCM, which is a separate integration. The rule stays the same: the push is a notification; the source of truth for money remains the backend and the database row.

const clients = new Map(); // userId -> [WebSocket channels]

function notifyApp({ userId, payload }) {
  const sockets = clients.get(userId) ?? [];
  for (const ws of sockets) ws.send(JSON.stringify(payload));
}
Enter fullscreen mode Exit fullscreen mode

The map is the whole server-side story: a connection registers its user, the map stores the channel, and the webhook handler publishes to the right sockets. When the user closes the app, the WebSocket disconnects and the channel leaves the map — which is exactly why the poll on reopen exists. Nothing about the user's payment state is lost when the socket closes, because the backend's database row never depended on the socket being open. The push is convenience; the database row is truth.

Handling Retries and the Delivery Window

A payment webhook is delivered at least once, and usually more than once. The gateways retry with backoff when your endpoint does not respond fast enough, and some re-deliver events for days after the original payment. Two consequences:

You must always return 200 fast. A 500 or a timeout tells the gateway "try again," which means duplicate deliveries, which means your idempotency layer gets exercised harder. Verify, store, enqueue, respond — in under two seconds.

You must accept out-of-order and late events. A settlement event for a payment made yesterday can arrive today because the network hiccuped. Never assume arrival order matches business order. Your order status should be derived from the stored event with the latest timestamp or the clearest business meaning, not from whichever webhook landed last.

For mobile specifically, this matters in a subtle way. When your app is closed, it misses the WebSocket push entirely. That is fine — the poll on reopen fetches the backend's stored truth. The app never needs to reconstruct money state from events; it only ever reads the backend's derived status. Keep that invariant and you can add or remove notification channels without touching correctness.

Important Notes and Pitfalls

  1. Verify the signature first, always. If you process the payload before verifying, you accept forged events. Anyone who knows your webhook URL can claim a payment. This is the #1 error I see in production codebases.
  2. Idempotency is not optional. Gateways retry deliveries on failure and timeouts, often with a backoff schedule of several days. Use the event ID as your dedupe key and return 200 for repeats.
  3. Store events, don't just process them. A raw payment_events table is your audit trail, your reconciliation tool, and your debugging log. You will want it the first time a chargeback happens.
  4. Never put the gateway secret or webhook secret in the Flutter app. The app cannot verify a webhook — it should not have the secret. All verification happens server-side.
  5. Handle slow and out-of-order events. Webhooks can arrive late or out of order (a refund before the payment event, in rare gateway edge cases). Timestamp everything and reconcile periodically rather than trusting arrival order.
  6. Use a library where the gateway provides one. Razorpay, Cashfree, and Stripe all ship signature-verification helpers. Using a hand-rolled HMAC compare opens the door to subtle timing bugs.
  7. Test with the gateway's test webhook sender. Every serious gateway lets you fire a sample webhook from the dashboard. Do that in CI or a staging environment before every release that touches payment code.

A Word About Timeouts and Retries

Webhook handlers should finish in a second or two and return a fast 200. If your handler does slow work — sending emails, calling external services — push that work onto a queue and return immediately. A slow webhook response causes the gateway to retry, which causes duplicate work, which your idempotency layer then has to swallow. The pattern is: verify fast, store, enqueue, respond fast.

Frequently Asked Questions

Do I need the WebSocket at all if I poll on every screen load? No, but then you wait for the user to reopen or navigate. The WebSocket gives you the instant confirmation for the checkout screen — the user stays on "processing" for seconds instead of "unknown." For payments, that instant feedback materially reduces abandoned orders. Keep both: push for the live moment, poll as the safety net.

Can the Flutter app receive the webhook directly via a background service? No. Your phone has no public URL and no stable long-running process to receive HTTP from a gateway. FCM push from your own backend is the closest thing — and the backend still has to receive and verify the webhook first.

What about test webhooks? Every serious gateway has a dashboard button to fire a sample webhook at your endpoint. Use it in staging before every release that touches payment code, and add a test case that asserts your idempotency layer returns 200 without double-processing when you replay the same event body.

How do I protect the webhook endpoint from being spammed? Signature verification handles authenticity. Add rate limiting and IP allowlisting of the gateway's published webhook ranges if your gateway provides them, and log all rejected signatures — a spike in bad signatures is an early sign someone is probing you.

Do refunds and failures also come as webhooks? Yes — the whole payment lifecycle does: payments, refunds, failures, mandate revocations, and settlement reports. Build one verified, idempotent ingest for all of them and let your business logic fan out per event type. That is the architecture that survives.

That's it — a complete, production-safe payment webhook architecture for a Flutter app. Gateway fires, backend verifies and stores, the app learns via WebSocket and re-polls on open. No forged events, no double charges, no trusting the client.

I have also written integrations for UPI AutoPay mandates, PayPal, and Stripe — comment below with your payment flow and I'll cover it next.


*Gulshan Yad

Top comments (0)