Skip to content
ArticleLaravel

Telr Payment Gateway Integration in PHP and Laravel (+ Flutter App)

D

Dinesh Wijethunga

May 16, 2026Reviewed Aug 1, 2026 13 min readIntermediate 783 views
ShareX / TwitterLinkedIn
Telr Payment Gateway Integration in PHP and Laravel (+ Flutter App)

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 statusprocessing, 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.

Test mode and per-currency credentials

Two configuration details cause more lost hours than anything in the integration itself, and neither is obvious from Telr's documentation.

The test flag goes on every request type

Telr does not have a separate sandbox host. You use the same endpoints and flip a flag on the transaction, which means the flag has to be set on all three of the request shapes this integration uses — the XML auth call, the hosted order, and the mobile verify order:

// One source of truth, read once in the constructor.
$this->testMode = (bool) config('payment.gateways.telr.test_mode', false);

// Remote XML (token auth)
'tran' => ['type' => $method, 'test' => $this->testMode ? 1 : 0],

// Hosted checkout (order.json)
'order' => ['cartid' => $cartId, 'test' => $this->testMode ? 1 : 0, /* ... */],

// Mobile SDK verify order (REST)
'test' => $this->testMode ? 1 : 0,

The failure mode is partial configuration: someone sets the flag on the hosted checkout while testing that flow, ships it, and the tokenization path quietly runs live transactions in staging. Read the flag once into a property and reference that property everywhere — never re-read config at each call site, because that is how one of them gets missed.

For the actual test card numbers, use the ones in your Telr merchant dashboard rather than any list you find online. They are per-account and they change; a card that worked in someone's 2023 blog post will simply decline.

Telr issues one store per currency

This is the one that surprises people. A Telr account is not a single set of credentials — you get a separate store ID and auth key for each currency you transact in. Hard-code one pair and every transaction in a second currency fails with an authentication error that says nothing about currency.

Key the config by currency and fail loudly when a pair is missing:

private function credentials(string $currency): array
{
    $storeId = config("payment.gateways.telr.{$currency}.store_id");
    $authKey = config("payment.gateways.telr.{$currency}.auth_key");

    if (! $storeId || ! $authKey) {
        throw new \RuntimeException(
            "Telr credentials are not configured for currency [{$currency}]. ".
            "Set TELR_{$currency}_STORE_ID and TELR_{$currency}_AUTH_KEY in your .env file."
        );
    }

    return ['store_id' => $storeId, 'auth_key' => $authKey];
}

Every call site then resolves credentials from the transaction currency:

$creds = $this->credentials($request->currency);

The explicit exception matters more than it looks. Without it, a missing currency pair sends empty credentials to Telr and you get a generic authentication failure — so you spend an afternoon checking a key that was never wrong, instead of reading one message that names the missing environment variable.

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

Frequently Asked Questions

Can I integrate Telr with plain PHP instead of Laravel?
Yes. Telr exposes ordinary HTTP APIs — remote.xml for tokenized authorization, order.json for hosted checkout, and a REST endpoint for the mobile SDK verify flow. Nothing in the integration depends on Laravel; the framework only supplies the HTTP client, config and logging. Any PHP application that can POST XML and JSON can do the same thing.
Does Telr have a sandbox environment?
Not a separate host. You call the same production endpoints and set a test flag on the transaction (test = 1). Because the flag lives on the request rather than the environment, it must be set on every request type you use — the XML auth call, the hosted order and the mobile verify order. Read it once from config into a property and reference that property everywhere.
Why do my Telr transactions fail in a second currency?
Telr issues a separate store ID and auth key for each currency on your account. Credentials that work for one currency will fail authentication for another, and the error message does not mention currency. Key your config by currency code and throw an explicit exception naming the missing environment variable when a pair is absent.
How does the Telr webhook know which flow a payment belongs to?
Telr's hosted checkout API has no native context field, so the routing context is embedded in the cartid — for example a context prefix followed by the order reference. The webhook handler splits that value to decide which service should process the callback. The cartid is the only field that survives the round trip and comes back in the webhook payload.
Where do I get Telr test card numbers?
From your Telr merchant dashboard. Test cards are tied to your account and change over time, so lists published in older articles frequently decline. Use the dashboard values together with test mode enabled on the transaction.
D
Dinesh Wijethunga

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

Was this post helpful?

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

You might also like

PHP 8.4 Property Hooks: The Feature That Changes EverythingFeatured
ArticleIntermediatePHP

PHP 8.4 Property Hooks: The Feature That Changes Everything

PHP 8.4 property hooks put get/set logic directly on the property — no more getter/setter boilerplate. Full syntax, virtual properties, asymmetric visibility, why they don't work on Eloquent models, and the rest of what shipped in 8.4.

D
Dinesh Wijethunga
3 months ago
5.6K7m