ArticleLaravel

Telr Payment Integration: Laravel Backend + Flutter Mobile App

D

Dinesh Wijethunga

May 16, 2026 13 min readIntermediate 322 views
πŸ“

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:

  1. Uses token_url to authenticate against the Telr order
  2. Presents a native card entry form (the Telr-hosted UI running inside a native WebView)
  3. Handles 3DS (3D Secure) authentication if the card issuer requires it
  4. 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:

ColumnValue
idUUID (your system's ID)
user_id42
gatewaytelr
gateway_method_idTOKEN_FROM_TELR_SDK
brandvisa
last_four4242
expiry_month12
expiry_year2028
is_defaulttrue

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:

  1. Reads the wallet row and locks it
  2. Increments balance by the recharge amount
  3. Increments total_recharged
  4. Creates a WalletTransaction record with type = top_up, balance_before, balance_after
  5. Updates WalletRecharge.status to completed

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:

  1. Creates a WalletRecharge record immediately with status = processing and a generated order_ref like RCHG-XK9PQR7ZAB
  2. Calls TelrGateway::createSession() using Telr's order.json API

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:

  1. Finds WalletRecharge by order_ref = 'RCHG-XK9PQR7ZAB'
  2. Updates gateway_transaction_ref and gateway_status
  3. 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

GoalFlutter callsBackend calls
Add a cardPOST /saved-methods/session then TelrSdk.addCard() then POST /saved-methodsTelr REST API /api/v1/orders (VERIFY)
Top-up with saved cardPOST /wallet/rechargeTelr remote.xml (SALE)
Top-up without saving cardPOST /wallet/recharge/hosted + open WebViewTelr order.json (hosted checkout)
Check top-up statusGET /wallet/recharges/{id}β€”
List saved cardsGET /wallet/saved-methodsβ€”
Delete saved cardDELETE /wallet/saved-methods/{id}β€”
Payment webhook(Telr calls your backend directly)Verifies signature β†’ credits wallet
D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.