← Blog/23 October 2026 · 6 min read

Online payments in a Flutter food app: what we learned wiring Paymob into Countasty

Countasty, our healthy-food delivery platform in Egypt, takes card, wallet and Apple Pay payments through Paymob. The happy path took a day. Failed payments, late webhooks and Apple Pay inside a web view took the rest of the week. Here is what we changed and why.

CountastyPaymentsFlutterLaravel

Countasty is our healthy-food delivery platform for the Egyptian market: a Flutter customer app, a rider app, a restaurant app and a Laravel backend. At launch, customers needed to pay online by card, by mobile wallet and, on iPhone, with Apple Pay. In Egypt the natural gateway for that is Paymob.

Getting a first successful payment took about a day. Making payments reliable took a lot longer, and almost all of that time went into the cases where something does not go to plan. This post walks through those cases.

How a Paymob payment flows

At a high level, an online order goes through five steps:

  • The app places the order with the payment method set to online, and the backend creates it as unpaid.
  • The backend asks Paymob for a payment session for that order's amount and gets back a checkout URL.
  • The app opens that URL so the customer can pay on Paymob's page.
  • Paymob tells our backend the result in two ways: a webhook (a server-to-server POST) and a redirect (the customer's browser is sent back to our callback URL).
  • The backend marks the order paid, and the app shows the success screen.

Both the webhook and the redirect carry an HMAC signature calculated with a secret only we and Paymob know. Our callback recalculates it and compares the two with hash_equals, a constant-time comparison, so nobody can mark an order paid by visiting the callback URL with made-up parameters.

Lesson one: the callback URLs have to be yours

Our first test payments succeeded on Paymob's side, and nothing happened on ours. Each Paymob payment integration has its own webhook and redirect URL fields, and ours were still set to Paymob's defaults. Paymob was dutifully reporting each payment to a page that was not ours.

Every integration we use (card, wallet, Apple Pay) now points both fields at the same callback endpoint, and checking those fields is the first item on our list whenever we add a new payment method.

Lesson two: handle the same result twice

Because Paymob sends both a webhook and a redirect, our callback usually hears about each payment twice, in no guaranteed order. Retries can make it three or four times.

So marking an order paid is idempotent. The callback looks up the order, checks the signature, and only flips it from unpaid to paid if it is not paid already. The second and later messages change nothing. Anything with side effects, such as notifying the restaurant, hangs off that single unpaid-to-paid transition, so it can never fire twice.

We also stopped relying on the PHP session to remember which order a payment belonged to. A webhook comes from Paymob's servers, not the customer's browser, so it has no session at all. The order reference we send to Paymob now carries our own payment ID plus a timestamp, which makes it unique for every attempt and lets the callback find the order with no session.

Lesson three: a failed payment should not lose the cart

Our first version handled failure badly. If a card was declined, or the customer closed the payment page, they saw a "Payment failed" dialog and landed on an empty cart. Their items, their coupon and the tip they had chosen were all gone, and they had to start again.

We rebuilt the flow around one rule: until money has actually moved, nothing the customer chose is thrown away.

  • The payment page is now pushed on top of the checkout screen instead of replacing it, so checkout is still there underneath.
  • A single function, finishDigitalPayment(paid, orderId), is the only way out of the payment page. It runs once per order no matter how the page closes: success redirect, failure redirect, the customer closing the browser, or the back button.
  • If the payment succeeded, it clears the cart and shows the success screen.
  • Otherwise it cancels the unpaid order, shows a short message, and leaves the customer on their checkout exactly as they left it, ready to try another card.

Lesson four: don't delete the order that failed

The obvious way to clean up a failed online payment is to delete the order. We deliberately don't.

Payments are asynchronous. A customer can close the payment page a second before their bank approves the charge, and Paymob's webhook then arrives after we have already decided the payment "failed". If the order no longer exists, that webhook has nothing to attach to, and we have taken someone's money for an order we cannot find.

So unpaid online orders are kept, and hidden instead. A query scope on the order model filters them out of the customer's order history and running-orders list, and cancelling one sends no push notification. If a late webhook does arrive with a valid signature, the order is still there to be marked paid and handled properly.

Lesson five: Apple Pay will not run inside a web view

The simplest way to show a payment page in Flutter is an in-app web view, and that works for cards and wallets. Apple Pay is different: Apple only allows Apple Pay on the web inside Safari or Safari-based views, not inside an embedded WKWebView. Our Apple Pay button simply did not appear.

The fix on iOS was to stop showing the checkout in the web view at all. When the app sees it is about to load Paymob's unified checkout page, it intercepts the navigation and opens the same URL in an SFSafariViewController sheet, which supports Apple Pay. In Flutter, the flutter_inappwebview package exposes this as ChromeSafariBrowser; you also need useShouldOverrideUrlLoading: true so the interception happens at all.

There is a catch. A Safari sheet does not report back to your app how the payment went. So when the customer closes it, the app asks our own backend for the order's payment status, a few times over a few seconds to allow for the webhook to arrive, and then follows the same paid or not-paid path as everywhere else.

Lesson six: small data details matter to the payment provider

Paymob's billing data asks for a first and a last name. Our app stores a single full-name field, and the first version sent that same full name as both. It worked, but receipts and the payment provider's review saw names like "Sara Ahmed Sara Ahmed". The backend now splits the name on the first space, so the first word becomes the first name and the remainder the last name.

It is a tiny change, but payment providers review real transactions before switching a merchant to live, and clean data makes that review faster.

Testing without touching real money

All of this was developed against a separate test environment: a copy of the backend on its own subdomain with its own database, payment settings in test mode, and push notifications switched off so test orders never reach real restaurants or riders. The mobile apps read their API address from a build-time setting, so a test build talks to the test backend. It also gets a red "TEST" band on its app icon, so nobody mistakes it for the real app on their phone.

Summary

The successful payment is the easy part. What makes online payments trustworthy is everything around it: callbacks you control, idempotent updates driven by signed messages, a failure path that keeps the customer's cart, unpaid orders that are hidden rather than deleted, and a Safari sheet wherever Apple Pay is involved. If you are adding payments to an app, budget for the unhappy paths first. They are most of the work, and they are what your customers will remember.

Next article
Deploying to shared hosting without FTP: lessons from cPanel's API →