Receive Crypto Payments in Laravel (Tron)
Build a Laravel payment gateway for TRX and TRC20 tokens: one address per session, a Chaingateway webhook, signature check and transaction verification.
Reviewed by Maurice Sobiera
A Laravel crypto payment gateway creates one blockchain address per payment session, registers a webhook on that address, and marks the session as paid when a verified notification arrives. This tutorial builds that flow for TRX and the JST token (TRC20) on Tron with the Chaingateway API. The code is a demo, not a production system.
The same pattern works for Bitcoin, Ethereum, BNB Smart Chain and Polygon with small changes to the endpoints. The demo code is available in the chaingateway-crypto-payment-demo-laravel repository.
Do not use this code in production. The demo stores private keys in plain text, which must never happen in a production environment. Chaingateway can also create password-protected addresses, where you send a
passwordinstead of storing a private key.
What does the tutorial build?
The result is a small payment gateway with these parts:
- A form that starts a payment session with an amount and a currency (TRX or JST).
- A new wallet address for each session.
- A session page that shows the address, the status, the expected amount and the received amount.
- A webhook endpoint that verifies the signature, checks the transaction, updates the session and, optionally, forwards the funds to a cold wallet.
How do I get an API key?
You create an API token in the Chaingateway account panel. Log in at app.chaingateway.io, open the user settings and select API Tokens. Click Create Token, name it (for example “Payment Gateway”) and copy the value. Every request sends it as a bearer token. The developer portal and the API reference describe the endpoints in detail.
You also need a Laravel project with a configured database. Follow the Laravel installation guide if you start from scratch. Basic knowledge of models, migrations, controllers and routes is enough.
Step 1: How do I configure the API client?
Add the base URL, the token, the network and your cold wallet address to config/app.php. The X-Network header selects the network: testnet for development and mainnet for production.
'Chaingateway' => [ 'api_url' => env('Chaingateway_API_URL', 'https://app.chaingateway.io/api/v2'), 'api_key' => env('Chaingateway_API_KEY'), 'network' => env('Chaingateway_NETWORK', 'testnet'), // Use 'mainnet' for production 'cold_wallet' => env('COLD_WALLET'), 'webhook_secret' => env('Chaingateway_WEBHOOK_SECRET'),],Then add the values to your .env file:
Chaingateway_API_URL=https://app.chaingateway.io/api/v2Chaingateway_API_KEY=your_api_key_hereChaingateway_NETWORK=testnetCOLD_WALLET=your_cold_wallet_addressChaingateway_WEBHOOK_SECRET=your_personal_secretThe webhook secret is the personal secret from your Chaingateway profile settings. Once you set it, every webhook carries an X-Signature header, which step 4 verifies.
Step 2: How do I define the routes?
Four routes cover the flow: the payment page, the start of a session, the session view and the webhook receiver. Add them to routes/web.php. The webhook route needs a name, because the controller builds its URL with route('handleWebhook').
use App\Http\Controllers\PaymentController;
Route::get('/payment', [PaymentController::class, 'showPaymentPage']);Route::post('/start-payment-session', [PaymentController::class, 'startPaymentSession']);Route::get('/payment-session/{id}', [PaymentController::class, 'showPaymentSession'])->name('showPaymentSession');Route::post('/webhook', [PaymentController::class, 'handleWebhook'])->name('handleWebhook');Laravel rejects POST requests without a CSRF token, and Chaingateway cannot send one. Exclude the webhook route in bootstrap/app.php. The Laravel CSRF documentation describes the same setting for older versions.
use Illuminate\Foundation\Application;use Illuminate\Foundation\Configuration\Exceptions;use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { // Exclude the webhook route from CSRF protection $middleware->validateCsrfTokens(except: [ 'webhook', ]); }) ->withExceptions(function (Exceptions $exceptions) { // })->create();Your webhook URL must be reachable from the public internet. During local development, expose the dev server through a tunnel.
Step 3: Which models and migrations do I need?
You need two tables: wallets with their keys, and payment sessions with their status. Generate both models with migrations.
php artisan make:model Wallet -mphp artisan make:model PaymentSession -mIn database/migrations/<timestamp>_create_wallets_table.php:
Schema::create('wallets', function (Blueprint $table) { $table->id(); $table->string('address')->unique(); // Wallet address $table->string('private_key'); // Demo only: never store keys in plain text $table->timestamps();});In database/migrations/<timestamp>_create_payment_sessions_table.php:
Schema::create('payment_sessions', function (Blueprint $table) { $table->id(); $table->string('status')->default('Pending'); // Pending, Completed, Failed, overpaid, underpaid $table->foreignId('wallet_id')->constrained()->onDelete('cascade'); $table->decimal('amount', 18, 8)->nullable(); // Amount the customer should send $table->string('currency')->default('TRX'); $table->decimal('received_amount', 18, 8)->nullable(); // Amount that arrived $table->string('webhook_id')->nullable(); $table->timestamps();});Run php artisan migrate, then make the fields fillable and define the relations.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Wallet extends Model{ protected $fillable = ['address', 'private_key'];
public function paymentSessions() { return $this->hasMany(PaymentSession::class); }}namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentSession extends Model{ protected $fillable = ['wallet_id', 'status', 'amount', 'currency', 'received_amount', 'webhook_id'];
public function wallet() { return $this->belongsTo(Wallet::class); }}Step 4: How does the PaymentController work?
The controller creates the wallet, registers the webhook and handles incoming notifications. Generate it with php artisan make:controller PaymentController.
Creating a session makes two API calls. POST /api/v2/tron/addresses returns a new address and, without a password, its private key. POST /api/v2/tron/webhooks registers a webhook for incoming transactions to that address and returns the webhook ID in data.webhook.
The webhook handler does four things in order. It verifies the X-Signature header. It looks up the session by the receiving address. It asks the API for the transaction and checks that ret[0].contractRet is SUCCESS. Only then does it update the session.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Client\PendingRequest;use Illuminate\Http\Request;use Illuminate\Support\Facades\Http;use App\Models\PaymentSession;use App\Models\Wallet;
class PaymentController extends Controller{ private $apiUrl; private $apiKey; private $network; private $coldWallet; private $webhookSecret;
public function __construct() { $this->apiUrl = config('app.Chaingateway.api_url'); $this->apiKey = config('app.Chaingateway.api_key'); $this->network = config('app.Chaingateway.network'); $this->coldWallet = config('app.Chaingateway.cold_wallet'); $this->webhookSecret = config('app.Chaingateway.webhook_secret'); }
private function api(): PendingRequest { return Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", 'Content-Type' => 'application/json', 'X-Network' => $this->network, ]); }
public function showPaymentPage() { return view('payment'); }
/** * Creates a wallet address and a webhook for it, then stores the session. */ public function startPaymentSession(Request $request) { $validated = $request->validate([ 'amount' => 'required|numeric|gt:0', 'currency' => 'required|in:TRX,JST', ]);
$response = $this->api()->post("{$this->apiUrl}/tron/addresses");
if (! $response->successful()) { return back()->withErrors(['error' => 'Failed to create payment session.']); }
$walletData = $response->json()['data'];
$wallet = Wallet::create([ 'address' => $walletData['address'], 'private_key' => $walletData['privateKey'], ]);
$webhookResponse = $this->api()->post("{$this->apiUrl}/tron/webhooks", [ 'to' => $wallet->address, 'url' => route('handleWebhook'), ]);
if (! $webhookResponse->successful()) { return back()->withErrors(['error' => 'Failed to create webhook.']); }
$paymentSession = PaymentSession::create([ 'wallet_id' => $wallet->id, 'webhook_id' => $webhookResponse->json()['data']['webhook'], 'amount' => $validated['amount'], 'currency' => $validated['currency'], 'status' => 'Pending', ]);
return redirect()->route('showPaymentSession', ['id' => $paymentSession->id]); }
public function showPaymentSession($id) { $paymentSession = PaymentSession::with('wallet')->findOrFail($id); return view('payment-session', compact('paymentSession')); }
public function handleWebhook(Request $request) { $transactionData = $request->all();
// 1. Verify that Chaingateway sent the notification (see the signature section) if (! $this->signatureIsValid($transactionData['txid'] ?? '', $request->header('X-Signature', ''))) { return response()->json(['error' => 'Invalid signature'], 401); }
// 2. Find the session for the receiving address $wallet = Wallet::where('address', $transactionData['to'])->first(); if (! $wallet) { return response()->json(['error' => 'Wallet not found'], 404); }
$paymentSession = PaymentSession::where('wallet_id', $wallet->id)->first(); if (! $paymentSession) { return response()->json(['error' => 'Payment session not found'], 404); }
// A notification can arrive twice. Acknowledge it without processing it again. if ($paymentSession->status !== 'Pending') { return response()->json(['status' => 'already processed']); }
// 3. Check on chain that the transaction succeeded $txResponse = $this->api()->get("{$this->apiUrl}/tron/transactions/{$transactionData['txid']}"); $contractRet = $txResponse->json('data.transaction.ret.0.contractRet');
if ($txResponse->successful() && $contractRet === 'SUCCESS') { $paymentSession->status = 'Completed'; $paymentSession->received_amount = $transactionData['amount'];
// Compare the received amount with the expected one and allow a margin. // Check in a test notification whether `amount` is in the token's smallest unit // or already a decimal value, and convert before you compare. $allowedDifference = $paymentSession->amount * 0.10; if (abs($paymentSession->amount - $transactionData['amount']) >= $allowedDifference) { $paymentSession->status = $transactionData['amount'] > $paymentSession->amount ? 'overpaid' : 'underpaid'; }
// For tokens, check that the contract is the one you expect (JST on Tron mainnet) if ($paymentSession->currency == 'JST' && ($transactionData['contractaddress'] ?? null) != 'TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3') { $paymentSession->status = 'Wrong currency received'; }
/** * Optional: forward the funds to a cold wallet. * A TRC20 transfer needs Energy, so the address must hold TRX or staked resources. * Chaingateway's Tron Paymaster is deprecated. Use TronFuel (https://tronfuel.dev) * if you do not want to handle the fees yourself. * * $endpoint = $transactionData['contractaddress'] * ? "{$this->apiUrl}/tron/transactions/trc20" * : "{$this->apiUrl}/tron/transactions"; * * $this->api()->post($endpoint, [ * 'amount' => $transactionData['amount'], * 'privatekey' => $wallet->private_key, * 'to' => $this->coldWallet, * 'from' => $transactionData['to'], * 'contractaddress' => $transactionData['contractaddress'], * ]); */ } else { $paymentSession->status = 'Failed'; }
// Delete the webhook if you will not reuse the address $this->api()->delete("{$this->apiUrl}/tron/webhooks/{$paymentSession->webhook_id}");
$paymentSession->save(); return response()->json(['status' => 'success']); }
private function signatureIsValid(string $txid, string $received): bool { if (! $this->webhookSecret) { return false; }
$expected = base64_encode(hash_hmac('sha256', $txid, $this->webhookSecret, true)); return hash_equals($expected, $received); }}The Pending check protects you against a repeated notification, which can happen when you resend a failed one. A notification that your server does not accept is not retried automatically. It appears in the list of failed notifications at GET /api/v2/tron/webhooks/notifications/failed, and you can resend it with POST /api/v2/tron/webhooks/notifications/{id}/retry.
How do I verify the webhook signature?
Compute an HMAC-SHA256 over the txid with your personal secret and compare it with the X-Signature header. The header holds the Base64-encoded result. HMAC is specified in RFC 2104. The signatureIsValid method above implements this with hash_hmac and compares with hash_equals, which takes constant time. The signature documentation lists the same check in other languages.
The demo rejects every notification if no secret is configured. A request without a valid signature could come from anyone who knows your webhook URL, so you should not skip the check.
Step 5: How do I build the views?
Two Blade views are enough: a form to start a session and a page that shows its state. In resources/views/payment.blade.php:
<!DOCTYPE html><html> <head> <title>Start Payment Session</title> </head> <body> <h2>Start a New Payment Session</h2> <form action="/start-payment-session" method="POST"> @csrf <label for="amount">Amount:</label> <input type="number" step="0.01" name="amount" id="amount" required /> <br /> <label for="currency">Currency:</label> <select name="currency" id="currency" required> <option value="TRX">TRX</option> <option value="JST">JST (TRC20)</option> </select> <br /> <button type="submit">Start Payment Session</button> </form> </body></html>In a real shop the checkout process starts the session, not a form. The session page below shows the address and the status. A real page would refresh the status through polling or websockets. In resources/views/payment-session.blade.php:
<!DOCTYPE html><html> <head> <title>Payment Session</title> </head> <body> <h2>Payment Session</h2> <p> Send <strong>{{ $paymentSession->amount }} {{ $paymentSession->currency }}</strong> to the address below: </p> <p><strong>{{ $paymentSession->wallet->address }}</strong></p> <p>Status: <strong>{{ $paymentSession->status }}</strong></p> <p> Received: <strong>{{ $paymentSession->received_amount }} {{ $paymentSession->currency }}</strong> </p> </body></html>Step 6: How do I test the flow?
Test on the testnet first. Start the server with php artisan serve, then follow these steps.
- Open
/paymentand start a new session. - Send testnet funds to the wallet address on the session page.
- Wait for the webhook, or send a signed notification to
/webhookyourself. - Reload the session page and check that the status changed.
To simulate a notification, post a payload like the one in the webhook documentation with a valid X-Signature header. Remember that the transaction check calls the API for the txid, so a made-up transaction ID ends in the Failed status.
What should I add before production?
Replace the demo shortcuts before you accept real payments.
- Create addresses with a password so that no private key is stored in your database.
- Credit a payment only after the number of confirmations that your risk policy requires.
- Compare amounts in the token’s smallest unit and match the contract address for every token.
- Reconcile open sessions against the API regularly, because a webhook can be missed.
For the deposit flow in more detail, read how crypto payment webhooks work and webhook delivery guarantees. For questions, see the support page.
Ready to build this yourself? Get your API key — 7-day trial, no card required — or see the Tron API for the full endpoint reference.