Telr Payment Integration: Laravel Backend + Flutter Mobile App
Dinesh Wijethunga
The Big Picture
When you integrate a payment gateway into a mobile app, you're actually juggling three distinct technical systems: your backend API (Laravel), the payment gateway (Telr), and the mobile client (Flutter). None of them can do the job alone.
Here's why each piece exists:
- Your backend holds your Telr credentials (Store ID, API Key). These never touch the mobile app. It also owns your business logic β what counts as a valid recharge, when to credit a wallet, how to prevent double-charges.
- Telr's servers handle the actual card processing, 3DS authentication, fraud detection, and card storage. You never see raw card numbers.
- The Flutter app presents UI to the user, calls your backend for session setup, then hands control to Telr's SDK for the payment step. It gets back a result token and reports it to your backend.
The flow is always: Flutter β your backend β Telr β your backend β Flutter. The mobile app never calls Telr directly with your credentials.
Part 1: Saving a Card (No Charge)
This is the most foundational flow. Before a customer can pay with a saved card, they need to add one.
What "saving a card" actually means
You never store the actual card number. Telr stores it on their PCI-compliant infrastructure and gives you back a token β a long opaque string that represents that card. Your database stores the token. When you want to charge that card later, you send Telr the token and they do the rest.
The three-step flow
Step 1: Flutter asks your backend to create a verification session
The Flutter app has no credentials to talk to Telr directly. So it asks your backend:
POST /api/v1/customer/wallet/saved-methods/session
Authorization: Bearer {passport_token}
Your SavedMethodController::session() receives this and calls TelrGateway::createVerifySession(). Inside that method, your backend calls Telr's new REST API:
POST https://secure.telr.com/api/v1/orders
Authorization: Basic {base64(store_id:auth_key)}
Content-Type: application/json
accept: application/json
{
"transactionType": "VERIFY",
"cartId": "ADD-42-xK9pQr7Z",
"amount": { "value": 0, "currency": "AED" },
"description": "Card tokenization",
"test": false,
"customer": {
"ref": "42",
"email": "[email protected]",
"name": { "firstName": "Ahmed", "lastName": "Al Rashid" },
"phone": "0501234567"
}
}
The key here is transactionType: "VERIFY". This tells Telr: create a checkout session that validates and tokenizes this card, but do not charge anything. The amount: 0 confirms no money will move.
Telr responds with:
{
"ref": "9430B9B7ECD3...",
"status": "PENDING",
"_links": {
"auth": {
"method": "GET",
"href": "https://secure.telr.com/api/v1/orders/9430B9B7ECD3.../auth?code=abc123"
},
"self": {
"method": "GET",
"href": "https://secure.telr.com/api/v1/orders/9430B9B7ECD3..."
}
}
}
Your backend extracts _links.auth.href as the token_url and _links.self.href as the order_url, then returns them to Flutter:
{
"data": {
"token_url": "https://secure.telr.com/api/v1/orders/9430.../auth?code=abc123",
"order_url": "https://secure.telr.com/api/v1/orders/9430..."
}
}
Your backend's job is done for this step. It created the session and handed the URLs to Flutter.
Step 2: Flutter SDK handles card entry
Flutter receives those two URLs and passes them directly to the Telr SDK:
final AddCardResponse result = await TelrSdk.addCard(
data['token_url'],
data['order_url'],
);
TelrSdk.addCard() is where the magic happens that you don't need to build. The SDK:
- Uses
token_urlto authenticate against the Telr order - Presents a native card entry form (the Telr-hosted UI running inside a native WebView)
- Handles 3DS (3D Secure) authentication if the card issuer requires it
- On success, returns an
AddCardResponse
if (result.success) {
final card = result.savedCards?.first;
// card.token β the card token to store on backend
// card.maskedCard β "**** 4242"
// card.expiry β "12/28"
// card.scheme β "VISA"
}
The SDK talks directly to Telr's servers using those URLs. Your backend never sees the raw card number.
Step 3: Flutter saves the token to your backend
Now Flutter has a Telr-issued token representing the card. It saves it to your backend:
POST /api/v1/customer/wallet/saved-methods
Authorization: Bearer {passport_token}
{
"gateway": "telr",
"gateway_method_id": "TOKEN_FROM_TELR_SDK",
"brand": "visa", (optional)
"last_four": "4242", (optional)
"expiry_month": 12, (optional)
"expiry_year": 2028, (optional)
"nickname": "My Visa", (optional)
"set_default": true
}
Your SavedMethodController::store() validates the payload and writes to saved_payment_methods:
| Column | Value |
|---|---|
id | UUID (your system's ID) |
user_id | 42 |
gateway | telr |
gateway_method_id | TOKEN_FROM_TELR_SDK |
brand | visa |
last_four | 4242 |
expiry_month | 12 |
expiry_year | 2028 |
is_default | true |
The unique constraint on (gateway, gateway_method_id) prevents the same physical card from being saved twice.
Response:
{
"data": {
"id": "018f1c23-9f29-7356-b7ab-2d657f183ee8",
"gateway": "telr",
"masked_card": "Visa β’β’β’β’ 4242",
"brand": "visa",
"last_four": "4242",
"expiry": "12/2028",
"is_expired": false,
"is_default": true,
"nickname": "My Visa",
"created_at": "2026-05-12T10:00:00+00:00"
}
}
From this point forward, 018f1c23-... is the saved_method_id the Flutter app uses in every subsequent payment request.
Part 2: Wallet Top-up With a Saved Card
Once a card is saved, charging it is a direct server-to-server operation. Flutter is barely involved.
The flow
Flutter sends:
POST /api/v1/customer/wallet/recharge
Authorization: Bearer {passport_token}
{
"amount": 200,
"saved_method_id": "018f1c23-9f29-7356-b7ab-2d657f183ee8",
"currency": "AED"
}
RechargeController::recharge() validates the request, looks up the SavedPaymentMethod by id AND user_id (so users can't charge each other's cards), checks the expiry, then calls WalletService::initiateRecharge().
Inside WalletService, your backend calls Telr's remote.xml API directly β this is a server-to-server call, no user interaction:
<remote>
<store>YOUR_STORE_ID</store>
<key>YOUR_API_KEY</key>
<tran>
<type>SALE</type>
<test>0</test>
<amount>200.00</amount>
<currency>AED</currency>
<card><token>TOKEN_FROM_TELR_SDK</token></card>
<order>
<ref>RCHG-ABC123</ref>
<description>Wallet top-up RCHG-ABC123</description>
</order>
</tran>
</remote>
SALE means authorize and capture in one step β no need for a separate capture call. Telr processes this and responds immediately with either success or failure.
On success, WalletService::completeRecharge() runs inside a DB transaction with a pessimistic lock (SELECT ... FOR UPDATE) to prevent race conditions:
- Reads the wallet row and locks it
- Increments
balanceby the recharge amount - Increments
total_recharged - Creates a
WalletTransactionrecord withtype = top_up,balance_before,balance_after - Updates
WalletRecharge.statustocompleted
All of this happens synchronously. Flutter gets back the result immediately:
{
"data": {
"recharge_id": "019e1c23-...",
"status": "completed",
"amount": 200,
"currency": "AED",
"gateway": "telr",
"completed_at": "2026-05-12T10:05:00+00:00"
}
}
The wallet is credited before the HTTP response leaves your server. No webhook needed for this path β the SALE response IS the confirmation.
Part 3: Wallet Top-up Without a Saved Card (Hosted Checkout)
This is the most architecturally interesting flow because it's asynchronous β Flutter doesn't wait for payment confirmation. Instead, Telr fires a webhook to your backend when the customer finishes paying.
Why this flow exists
Some customers don't want to save their card. They want to pay once, enter their card in a secure form, and never have it stored. The hosted checkout flow accommodates this without any compromise on security.
Phase A: Session creation
Flutter sends:
POST /api/v1/customer/wallet/recharge/hosted
Authorization: Bearer {passport_token}
{
"amount": 100,
"currency": "AED"
}
RechargeController::rechargeHosted() calls WalletService::initiateHostedRecharge(), which:
- Creates a
WalletRechargerecord immediately withstatus = processingand a generatedorder_reflikeRCHG-XK9PQR7ZAB - Calls
TelrGateway::createSession()using Telr'sorder.jsonAPI
The order.json payload is a nested JSON structure (this matters β Telr returns E01: Invalid request if you send flat form-encoded fields):
POST https://secure.telr.com/gateway/order.json
Content-Type: application/json
{
"method": "create",
"store": "YOUR_STORE_ID",
"authkey": "YOUR_API_KEY",
"framed": 0,
"order": {
"cartid": "wallet_recharge_hosted:RCHG-XK9PQR7ZAB",
"test": 0,
"amount": "100.00",
"currency": "AED",
"description": "Wallet top-up RCHG-XK9PQR7ZAB"
},
"return": {
"authorised": "https://api.dineshstack.ae/api/v1/customer/wallet/recharge/return/...",
"declined": "https://api.dineshstack.ae/api/v1/customer/wallet/recharge/cancel/...",
"cancelled": "https://api.dineshstack.ae/api/v1/customer/wallet/recharge/cancel/..."
}
}
Notice cartid: "wallet_recharge_hosted:RCHG-XK9PQR7ZAB". This is a deliberate design decision. Telr's order.json API has no native routing context field, so the context is encoded as a prefix in the cart ID with a colon separator (context:actualRef). When the webhook fires later, the backend splits on : to recover both the context and the reference ID. This makes webhook routing self-contained without relying on any Telr-specific feature.
Telr responds:
{
"order": {
"ref": "9430B9B7ECD3...",
"url": "https://secure.telr.com/gateway/process.html?o=9430B9B7ECD3..."
},
"method": "create"
}
Your backend stores checkout_url on the WalletRecharge record and returns to Flutter:
{
"data": {
"recharge_id": "019e1c23-...",
"status": "processing",
"amount": 100,
"currency": "AED",
"gateway": "telr",
"checkout_url": "https://secure.telr.com/gateway/process.html?o=9430B9B7..."
}
}
Phase B: Customer pays in Flutter WebView
Flutter receives checkout_url and opens it in a WebView:
// Using webview_flutter or in_app_webview
WebViewController()
..loadRequest(Uri.parse(data['checkout_url']));
The customer sees Telr's hosted payment page β entirely inside Telr's domain. The customer enters their card number, CVV, expiry. Telr handles 3DS if required. Your app never sees the card data. Flutter just hosts a browser window.
When the customer completes (or abandons) payment, Telr redirects the WebView to the return.authorised or return.declined URL you provided. Flutter can detect this URL change to close the WebView and update its UI:
NavigationDelegate(
onNavigationRequest: (request) {
if (request.url.contains('/recharge/return/')) {
// Payment may be complete β poll backend or show "processing" UI
closeWebView();
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
)
Important: The redirect URL is for WebView navigation only. It is not the payment confirmation. The actual confirmation comes via webhook.
Phase C: Webhook confirms payment
When Telr processes the payment, it fires a POST to your configured webhook URL (https://api1.dineshstack.ae/api/v1/webhooks/telr):
POST /api/v1/webhooks/telr
tran_ref=TXN123456
tran_authstatus=A
tran_type=AUTH
cart_id=wallet_recharge_hosted:RCHG-XK9PQR7ZAB
tran_amount=100.00
tran_currency=AED
tran_hash=sha1(auth_key + tran_ref + cart_id + tran_authstatus)
WebhookController::handleTelr() first verifies the signature:
$expected = sha1($authKey . $tranRef . $cartId . $status);
hash_equals($expected, $received); // timing-safe comparison
This prevents anyone from forging a webhook to credit your users' wallets fraudulently.
After verification, parseWebhook() splits cart_id on ::
$parts = explode(':', 'wallet_recharge_hosted:RCHG-XK9PQR7ZAB', 2);
// $parts[0] = 'wallet_recharge_hosted' β routing context
// $parts[1] = 'RCHG-XK9PQR7ZAB' β the actual order ref
processTelrEvent() routes to handleTelrHostedRecharge() based on context. That method:
- Finds
WalletRechargebyorder_ref = 'RCHG-XK9PQR7ZAB' - Updates
gateway_transaction_refandgateway_status - Calls
WalletService::completeRecharge()β same DB-transactioned, pessimistically-locked balance mutation as the saved-card path
completeRecharge() is idempotent. If Telr fires the webhook twice (they retry on failure), the second call hits the isCompleted() check and returns early without double-crediting.
public function completeRecharge(WalletRecharge $recharge): WalletTransaction
{
if ($recharge->isCompleted()) {
return WalletTransaction::where('reference_id', $recharge->id)->firstOrFail();
}
// ... credit wallet
}
The webhook endpoint returns 200 OK to Telr. Telr stops retrying.
Phase D: Flutter polls for status
Since payment confirmation arrives asynchronously via webhook, Flutter can't know immediately if the recharge succeeded. The app should poll:
GET /api/v1/customer/wallet/recharges/{recharge_id}
Which returns the current status β processing, completed, or failed. Flutter polls every few seconds after the WebView redirects, showing the user a "Verifying payment..." state until status transitions.
The Webhook Context Routing System
All three payment types ultimately flow through a single webhook endpoint. Here's how the router works in full:
Incoming Telr webhook
β
βΌ
verifyWebhook() β SHA1 signature check
β
βΌ (verified)
parseWebhook()
ββ reads cart_id (e.g. "wallet_recharge_hosted:RCHG-ABC")
ββ tries cart_id_context POST param first (legacy compat)
ββ splits on ':' to extract context + referenceId
β
βΌ
processTelrEvent()
ββ 'wallet_recharge' β handleTelrRecharge()
β looks up by gateway_transaction_ref (saved-card SALE)
β
ββ 'wallet_recharge_hosted' β handleTelrHostedRecharge()
β looks up by order_ref (hosted checkout)
β
ββ 'hosted_card_payment' β handleTelrHostedPayment()
β calls PaymentSessionService::confirm() for booking payments
β
ββ default β handleTelrBookingPayment()
logs the event (saved-card booking AUTH/CAPTURE/VOID via remote.xml)
The encoding trick (context:ref in cartid) is the glue that makes this routing reliable without depending on any Telr-specific custom field. Telr echoes cartid verbatim in the webhook as cart_id, so the round-trip is lossless.
Database Design
Three tables matter here:
wallets β one per (user, currency):
id, user_id, currency, balance, locked_balance, total_recharged, total_spent, is_active
wallet_recharges β one per top-up attempt:
id (UUID), user_id, wallet_id, amount, currency, payment_gateway,
saved_method_id (FK, null for hosted),
order_ref (for hosted checkout correlation),
checkout_url (returned to Flutter),
gateway_transaction_ref (Telr tran_ref),
gateway_status, status (processing/completed/failed),
failure_reason, completed_at, metadata
saved_payment_methods β one per saved card:
id (UUID), user_id, gateway, gateway_method_id (Telr token),
brand, last_four, expiry_month, expiry_year, is_default, nickname
UNIQUE(gateway, gateway_method_id)
wallet_transactions β immutable ledger of every balance change:
id (UUID), user_id, type (top_up/booking_debit/cancellation_fee/driver_credit/...),
direction (credit/debit), amount, balance_before, balance_after,
reference_type, reference_id, description, currency, performed_by
The ledger is append-only and never mutated. Every mutation to wallet.balance produces a corresponding WalletTransaction record. The balance on the wallet row is a running total β it's kept in sync by WalletService::mutateBalance(), which wraps every mutation in a SELECT ... FOR UPDATE transaction to prevent concurrent over-draws.
Security Considerations
Credentials never leave the server. The Flutter app never knows your Telr Store ID or API Key. It only ever receives URLs (for hosted flows) or results (for SDK flows).
Webhook signature verification. Every incoming Telr webhook is validated with a SHA1 HMAC before any processing. An invalid signature returns 401 immediately.
Idempotent balance mutations. completeRecharge() checks isCompleted() before crediting. The DB transaction + pessimistic lock ensures that even with concurrent webhook retries or race conditions, the wallet is only credited once.
Mass-assignment protection. The WalletRecharge model's $fillable explicitly lists every field that can be set via create() or update(). Fields like status and completed_at are set only by service methods, not from user input.
User-scoped lookups. Every query for a saved method, recharge record, or wallet operation is scoped by user_id. A user can never read or charge another user's card.
Expiry checks. RechargeController calls $savedMethod->isExpired() before initiating any charge. Expired cards are rejected with a 422 before hitting Telr.
Summary: Which API Does What
| Goal | Flutter calls | Backend calls |
|---|---|---|
| Add a card | POST /saved-methods/session then TelrSdk.addCard() then POST /saved-methods | Telr REST API /api/v1/orders (VERIFY) |
| Top-up with saved card | POST /wallet/recharge | Telr remote.xml (SALE) |
| Top-up without saving card | POST /wallet/recharge/hosted + open WebView | Telr order.json (hosted checkout) |
| Check top-up status | GET /wallet/recharges/{id} | β |
| List saved cards | GET /wallet/saved-methods | β |
| Delete saved card | DELETE /wallet/saved-methods/{id} | β |
| Payment webhook | (Telr calls your backend directly) | Verifies signature β credits wallet |
Reviews & Ratings
Sign in to leave a review.
