Laravel Crypto Payment Integration: Accept USDT and TRX
A Laravel crypto payment integration with mercanpay-php: container binding, USD-priced invoices, a CSRF-exempt webhook route and idempotent queued jobs.

A Laravel crypto payment integration comes together in a handful of files with the official mercanpay/mercanpay-php library. You bind the client in the service container, create a USD-priced invoice at checkout, redirect the customer to the hosted payment page, and fulfil the order from a queued job when a signed webhook arrives. This guide wires USDT and TRX (TRC20) payments into a Laravel app step by step.
There is no dedicated Laravel package for MercanPay. The PHP library is small and dependency-free, though, so building a clean integration with Laravel's own tools is straightforward. General library usage is covered in the PHP crypto payment integration guide; here we focus on the Laravel-specific parts.
Requirements
- PHP 8 or newer with
ext-curlandext-json - A Laravel app with a configured queue driver (examples use the Laravel 11 structure)
- An approved MercanPay merchant account and an API key with the
invoicesscope
No account yet? Start with the merchant application.
1. Install and configure the library
composer require mercanpay/mercanpay-php
Add your credentials to .env:
MERCANPAY_URL=https://mercanpay.com
MERCANPAY_API_KEY=mp_...
MERCANPAY_WEBHOOK_SECRET=whsec_...
Then add an entry to config/services.php. Reading values through config() rather than env() keeps them working when the config cache (php artisan config:cache) is on:
// config/services.php
'mercanpay' => [
'url' => env('MERCANPAY_URL', 'https://mercanpay.com'),
'key' => env('MERCANPAY_API_KEY'),
'webhook_secret' => env('MERCANPAY_WEBHOOK_SECRET'),
],
2. Bind the client in the service container
Register the client as a singleton in AppServiceProvider so you can inject it into controllers and jobs:
// app/Providers/AppServiceProvider.php
use MercanPay\MercanPay;
public function register(): void
{
$this->app->singleton(MercanPay::class, fn () => new MercanPay(
config('services.mercanpay.url'),
config('services.mercanpay.key'),
));
}
3. Create a Laravel crypto payment invoice at checkout
When the customer clicks "Pay", a controller creates the invoice and redirects to its payment_url:
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use MercanPay\MercanPay;
use MercanPay\MercanPayException;
class CheckoutController extends Controller
{
public function pay(Order $order, MercanPay $mercanpay)
{
try {
$invoice = $mercanpay->createUsdInvoice($order->total_usd, [
'order_id' => 'order-' . $order->id,
'description' => 'Order #' . $order->id,
'success_url' => route('orders.thanks', $order),
'return_url' => route('cart'),
'metadata' => ['user_id' => $order->user_id],
]);
} catch (MercanPayException $e) {
Log::warning('MercanPay', ['code' => $e->getErrorCode(), 'message' => $e->getMessage()]);
return back()->withErrors(['payment' => 'The payment page could not be opened. Please try again.']);
}
return redirect()->away($invoice['payment_url']);
}
}
Points worth knowing:
- Amounts are strings. Store
total_usdas adecimalcolumn and send it as a string such as'49.90', never a float. order_idis idempotent. A second request with the sameorder_idreturns the existing invoice, so a double click never creates two invoices. More in order_id idempotency for invoices.- The customer picks the coin. With
createUsdInvoicethe customer chooses TRX or USDT on the payment page. To accept only USDT, add'tokens' => ['USDT']. - The minimum invoice is $10, and network fees are included in the amount the customer pays.
4. Exempt the webhook route from CSRF
Laravel requires a CSRF token on POST requests to web routes. The server-to-server webhook from MercanPay has none, so the route must be excluded from CSRF checks. The HMAC signature protects it instead.
// routes/web.php
use App\Http\Controllers\MercanPayWebhookController;
Route::post('/mercanpay/webhook', MercanPayWebhookController::class)->name('mercanpay.webhook');
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['mercanpay/webhook']);
})
On Laravel 10 and earlier, add the same path to the $except array in app/Http/Middleware/VerifyCsrfToken.php. Then enter the full URL under Settings → Webhook URL in the panel.
5. Verify the signature and queue the work
The signature covers the raw request body, so use $request->getContent() rather than $request->all() or $request->json():
// app/Http/Controllers/MercanPayWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\HandleMercanPayEvent;
use Illuminate\Http\Request;
use MercanPay\MercanPay;
class MercanPayWebhookController extends Controller
{
public function __invoke(Request $request)
{
$body = $request->getContent();
$header = (string) $request->header('X-MercanPay-Signature', '');
if (!MercanPay::verifyWebhook(config('services.mercanpay.webhook_secret'), $body, $header)) {
abort(401);
}
HandleMercanPayEvent::dispatch(json_decode($body, true));
return response()->noContent(); // 204
}
}
verifyWebhook parses the t=…,v1=… header, rejects timestamps older than five minutes and compares the HMAC-SHA256 signature in constant time. The theory is in how to verify webhook signatures.
Queueing matters because a request that doesn't get a 2xx within 10 seconds is retried. Keep slow work such as emails or licence generation in the job, and the webhook response stays fast.
6. Handle each event exactly once
Because webhooks can be retried, the same event may arrive more than once. Store the event id in a unique column so each event is processed only once:
// migration
Schema::create('processed_webhook_events', function (Blueprint $table) {
$table->string('event_id')->primary();
$table->timestamps();
});
// app/Jobs/HandleMercanPayEvent.php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class HandleMercanPayEvent implements ShouldQueue
{
use Queueable;
public function __construct(public array $event) {}
public function handle(): void
{
DB::transaction(function () {
$inserted = DB::table('processed_webhook_events')->insertOrIgnore([
'event_id' => $this->event['id'], 'created_at' => now(), 'updated_at' => now(),
]);
if ($inserted === 0) {
return; // already handled
}
$invoice = $this->event['data']['invoice'] ?? null;
if (!$invoice) {
return; // withdrawal.* or test.ping
}
$orderId = (int) str_replace('order-', '', $invoice['order_id']);
match ($this->event['type']) {
'invoice.paid', 'invoice.overpaid' => Order::whereKey($orderId)
->where('status', '!=', 'paid')
->update(['status' => 'paid', 'paid_token' => $invoice['token'], 'paid_amount' => $invoice['paid_amount']]),
'invoice.partial' => Order::whereKey($orderId)->update(['status' => 'partially_paid']),
'invoice.expired', 'invoice.cancelled' => Order::whereKey($orderId)
->where('status', 'pending')->update(['status' => 'cancelled']),
default => null,
};
});
}
}
On Laravel 10, use the Dispatchable, InteractsWithQueue, Queueable, SerializesModels traits instead of the single Queueable trait. Retries and idempotency are covered in depth in webhook retries and idempotent handling, and every event type is listed in crypto payment webhook events.
7. Ask the API on the thank-you page
The success_url redirect can be forged, so don't fulfil orders from it. When you need certainty, query the API:
public function thanks(Order $order, MercanPay $mercanpay)
{
$invoice = $mercanpay->getInvoiceByOrder('order-' . $order->id);
$paid = in_array($invoice['status'], ['paid', 'overpaid'], true);
return view('orders.thanks', compact('order', 'paid'));
}
Common pitfalls and how to fix them
Most problems in Laravel integrations come from a few familiar places. Walking through them before launch saves you surprises on the first real payment.
Signing parsed data instead of the raw body
The signature is computed over the exact bytes MercanPay sends. If you re-encode the array from $request->all() as JSON, whitespace, key order or number formatting can change and the signature won't match. Always verify the text from $request->getContent(), and decode JSON only after verification.
Forgetting the CSRF exception
If the webhook route sits in the web group without a CSRF exception, Laravel rejects the request with a 419 before it reaches your controller. You'll see the 419 in the delivery history on the panel's Webhooks page. After adding the exception, you can resend the same event from there.
The queue worker isn't running
Because the webhook returns 204, MercanPay considers the delivery successful. But if the queue worker has stopped, the job never runs and orders stay pending. Keep queue:work running under a process manager in production and check failed jobs (failed_jobs) regularly. With the sync driver the job runs inside the request, which is handy in development, but slow work can push the webhook response past 10 seconds.
Config caching and env()
After php artisan config:cache, calls to env() in your code return null. Reading the key and secret only through config/services.php avoids this. Remember to rebuild the cache whenever you change .env.
Updating order status unconditionally
Events can arrive more than once or out of order. Conditions such as where('status', '!=', 'paid') stop a late event from knocking a paid order back to cancelled. Write every update around the question "does this transition make sense from the current state?"
Testing and going live
You can test end to end in three steps:
- Check connectivity. Send a
test.pingfrom Webhooks → "Send test" in the panel. The delivery history should show a 2xx response. Your job skipstest.pingquietly because it carries no invoice. - Exercise the invoice flow. Create a small real order, confirm you're redirected to the payment page, and check that the thank-you page queries the API when you come back via
success_url. - Complete the payment. The payment is final after 20 confirmations. When
invoice.paidarrives, confirm the order is updated and a row is added toprocessed_webhook_events. Then resend the same event from the panel and check the order isn't processed twice.
Go-live checklist
- The API key and webhook secret live only in
.env, never in the repository. - The webhook route is CSRF-exempt and signatures are verified.
- A queue worker (
php artisan queue:work) runs permanently in production. - A
test.pingfrom Webhooks → "Send test" in the panel was received successfully. - The whole flow was tested end to end with a small real payment.
FAQ
Is there a ready-made Laravel package?
No. You use the official PHP library bound in the container as shown above. It's only a few files and leaves you in full control.
Why does my webhook return 419?
A 419 means CSRF verification failed. Make sure the webhook path is in the CSRF exceptions.
Why does signature verification always fail?
The usual cause is hashing parsed JSON instead of the raw body. Use $request->getContent() and make sure no middleware rewrites the body. Your server clock also needs to be accurate.
Can I send payouts from Laravel?
Yes. With a key that has the withdrawals scope, call createWithdrawal('USDT', '50', 'T...', 'unique-key'). For security, API withdrawals only go to saved addresses marked as trusted in the panel.
Get started with MercanPay
To add USDT and TRX payments to your Laravel app, apply for a merchant account, create an API key and issue your first invoice. Every field and error code is in the documentation.


