Showing a USDT checkout page from a Laravel app takes about ten minutes. Making sure the right order is marked paid exactly once, when a customer pays from an exchange that batches withdrawals and your webhook endpoint happens to be mid-deploy, takes more care. This guide builds the second version.

We'll use a hosted invoice for the checkout, so you never touch wallet addresses or blockchain monitoring yourself. On the Laravel side we add a small amount of plumbing: a payments table, a webhook route that verifies signatures before trusting anything, a queued job that applies status changes idempotently, and a scheduled command that catches anything the webhook missed.

The examples use the official OxaPay Laravel SDK, but the structure works with any provider that gives you invoices, a payment lookup endpoint and signed webhooks. Code targets Laravel 11 on PHP 8.2. If your store runs on WooCommerce, WHMCS or a similar platform, a ready-made plugin will serve you better than custom code; this guide is for apps where payments are part of your own codebase.

The flow, and why the order is not the payment

Here is the whole lifecycle we're building:

  1. The customer places an order. Your app creates a local payments row before talking to the provider.
  2. Your backend creates an invoice and stores the returned track_id and payment URL.
  3. The customer is redirected to the hosted payment page, picks a network and sends USDT.
  4. The provider sends a signed webhook when the status changes, first paying, later paid.
  5. Your webhook route verifies the signature, stores the event and returns ok immediately.
  6. A queued job applies the status change to the payment, and to the order only when the payment is actually paid.
  7. A scheduled command periodically asks the provider about payments that are still open, in case a webhook never arrived.

The design decision that makes the rest easy is keeping the order and the payment as separate records. An order is a commercial fact: someone wants to buy something for a price. A payment is an attempt to settle it, and one order can have several. The first invoice expires because the customer went to dinner, the second one gets paid. If you put payment_status on the orders table, you'll find yourself overwriting history you later need for support.

Two tables cover it. The first holds one row per invoice:

// database/migrations/2026_10_31_000001_create_payments_table.php Schema::create('payments', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained()->cascadeOnDelete(); $table->string('track_id')->nullable()->unique(); $table->string('status')->default('new'); $table->decimal('amount', 18, 6); $table->string('currency', 10); $table->string('payment_url')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamp('paid_at')->nullable(); $table->json('last_payload')->nullable(); $table->timestamps(); $table->index(['status', 'updated_at']); }); 

The second is an append-only log of every webhook we accept:

// database/migrations/2026_10_31_000002_create_payment_events_table.php Schema::create('payment_events', function (Blueprint $table) { $table->id(); $table->string('track_id')->index(); $table->string('status'); $table->string('source'); // webhook | reconcile $table->char('payload_hash', 64)->unique(); $table->json('payload'); $table->timestamp('processed_at')->nullable(); $table->timestamps(); }); 

The event log looks like overhead until the first time a customer says they paid and your order says pending. With it, you can answer what the provider told you and when in one query, instead of reading through application logs. The unique payload_hash also gives us cheap protection against the same delivery being stored twice.

Amounts are stored as decimal, never float. You'll be comparing what you asked for with what arrived, and floating-point values can't represent most decimal prices exactly, so sooner or later a correct payment fails an equality check.

Install and configure the SDK

The official package supports Laravel 8 through 12. Install it and publish the config file:

composer require oxapay/oxapay-laravel php artisan oxapay:install 

Then add your keys to .env:

OXAPAY_MERCHANT_KEY=your_merchant_api_key OXAPAY_PAYMENT_CALLBACK_URL=https://your-app.com/webhooks/oxapay OXAPAY_SANDBOX=true 

The merchant key does two jobs: it authenticates your API calls, and it is the shared secret used to sign the webhooks you receive. Treat it like a database password. It belongs in .env and your secret manager, never in the repository, and rotating it means updating both your app and anything that verifies callbacks.

You'll see two other keys in the published config. The payout key is for sending funds out and the general key is for things like swaps. Leave them empty unless you need them. A checkout-only app has no reason to hold a key that can move money out of the account.

OXAPAY_SANDBOX=true sends invoice requests to the test environment. Note that in the SDK's config, sandbox mode applies only to invoice creation; lookups and other endpoints always hit production. That's fine for this guide, but worth knowing before you test anything else.

Setting the callback URL in config means every invoice carries it automatically. You can also pass it per request, which helps if you run several apps against one merchant account.

Create the invoice from the backend

Invoice creation always happens on the server. If the browser can create invoices, the browser can also choose the amount.

// app/Http/Controllers/CheckoutController.php namespace App\Http\Controllers; use App\Models\Order; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Log; use OxaPay\Laravel\Exceptions\OxaPayException; use OxaPay\Laravel\Support\Facades\OxaPay; class CheckoutController extends Controller { public function pay(Order $order): RedirectResponse { abort_unless($order->status === 'pending', 409); // A double click or a back-button retry should reuse the open invoice. $open = $order->payments() ->whereIn('status', ['new', 'waiting', 'paying']) ->where('expires_at', '>', now()->addMinutes(5)) ->latest() ->first(); if ($open) { return redirect()->away($open->payment_url); } $payment = $order->payments()->create([ 'amount' => $order->total, 'currency' => 'USDT', ]); try { $invoice = OxaPay::payment()->generateInvoice([ 'amount' => (float) $payment->amount, // JSON number for the API only 'currency' => 'USDT', 'lifetime' => 60, 'order_id' => (string) $payment->id, 'description' => "Order #{$order->id}", 'email' => $order->customer_email, 'return_url' => route('orders.show', $order), ]); } catch (OxaPayException $e) { // Leave the row as 'new': on a timeout the invoice may exist anyway, // and a late webhook or the reconcile job can still claim it. Log::warning('Invoice creation failed', [ 'payment_id' => $payment->id, 'error' => $e->getMessage(), ]); return back()->with('error', 'We could not start the payment. Please try again.'); } $payment->update([ 'track_id' => $invoice['track_id'], 'payment_url' => $invoice['payment_url'], 'expires_at' => now()->setTimestamp($invoice['expired_at']), ]); return redirect()->away($invoice['payment_url']); } } 

A few choices here are deliberate.

The local row is created before the API call. If the request times out after the provider created the invoice, you still have a record that an attempt happened, and the reconciliation step later can match it up. Creating the row afterwards means a lost response leaves no trace at all.

order_id is set to our payment ID, not the order ID. We'll look payments up by track_id, but order_id comes back in every webhook, which gives us a second, independent check that a callback belongs to the row we think it does.

currency sets the unit of the price, not the coin the customer must use. On the hosted invoice page the customer picks from the currencies your merchant account accepts. If your goal is to end up holding USDT regardless of what the customer sends, add 'to_currency' => 'USDT'. The Generate Invoice reference notes that paid crypto can only be converted to USDT, which is exactly the case we want.

lifetime is in minutes, between 15 and 2880, with 60 as the default. Shorter is not automatically safer. A customer paying from an exchange account may wait for a withdrawal to be processed, and an invoice that expires in 15 minutes turns that delay into a support ticket.

Two more fields deserve a conscious decision rather than the dashboard default. under_paid_coverage sets how far below the requested amount a payment can land and still count as paid. Exchanges often deduct their withdrawal fee from the amount sent, so a small tolerance avoids flagging honest customers, but every point of it is money you agree not to collect. fee_paid_by_payer decides who absorbs the invoice fee. Both are business policy, so agree on them with whoever owns pricing before you ship.

Finally, return_url is where the customer lands after paying. It is a UX feature, not a payment confirmation. Anyone can open that URL. The order page it points to should read the status from your own database and say "waiting for confirmation" until the webhook says otherwise.

The webhook route: verify, store, acknowledge

The webhook endpoint has one job: decide whether the request is genuine, write it down, and answer quickly. All business logic happens later, in a queued job.

The provider signs each callback with an HMAC-SHA512 of the raw request body, using your merchant key as the secret, and sends it in an HMAC header. Your endpoint must return HTTP 200 with the body ok. Anything else counts as a failure, and the webhook documentation describes the retry schedule: up to five attempts, roughly 1 minute, 3 minutes, 30 minutes and 3 hours apart. If your endpoint is broken for longer than that window, those notifications are gone, which is why we'll add a reconciliation job later.

First, exclude the route from CSRF protection. In Laravel 11 that lives in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['webhooks/oxapay']); }) 

Then register the route in routes/web.php:

Route::post('/webhooks/oxapay', OxaPayWebhookController::class)->name('webhooks.oxapay'); 

And the controller:

// app/Http/Controllers/OxaPayWebhookController.php namespace App\Http\Controllers; use App\Jobs\ProcessPaymentEvent; use App\Models\PaymentEvent; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; use OxaPay\Laravel\Exceptions\WebhookNotReceivedException; use OxaPay\Laravel\Exceptions\WebhookSignatureException; use OxaPay\Laravel\Support\Facades\OxaPay; class OxaPayWebhookController extends Controller { public function __invoke(Request $request): Response { try { // Verifies the HMAC header against the raw body before returning data. $data = OxaPay::webhook()->getData(); } catch (WebhookSignatureException|WebhookNotReceivedException $e) { Log::warning('Rejected OxaPay webhook', [ 'reason' => $e->getMessage(), 'ip' => $request->ip(), ]); return response('rejected', 400); } // Signed, but not an invoice update: acknowledge and move on. if (($data['type'] ?? null) !== 'invoice' || empty($data['track_id'])) { return response('ok'); } $event = PaymentEvent::createOrFirst( ['payload_hash' => hash('sha256', $request->getContent())], [ 'track_id' => (string) $data['track_id'], 'status' => strtolower((string) ($data['status'] ?? '')), 'source' => 'webhook', 'payload' => $data, ], ); if ($event->wasRecentlyCreated) { ProcessPaymentEvent::dispatch($event->id); } return response('ok'); } } 

The SDK's getData() does the signature check for you: it computes the HMAC over $request->getContent() and compares it with hash_equals, so the comparison is constant-time. What matters on your side is that nothing touches the payload before that check passes. Don't log it, don't look up the order with it, don't branch on its status.

createOrFirst is used instead of firstOrCreate on purpose. Retries can arrive close together, and firstOrCreate does a select followed by an insert, so two concurrent deliveries can both miss the select and the second insert fails on the unique index. createOrFirst tries the insert first and falls back to reading the existing row when the unique constraint fires.

Hashing the raw body catches exact duplicate deliveries. It does not catch two different payloads that describe the same change, for example a retry that carries a slightly different timestamp. That case is handled one level down, in the job, by refusing transitions that don't move the payment forward.

One operational note: the provider won't deliver callbacks to private addresses such as localhost, and some firewalls or CDNs block unfamiliar server-to-server traffic. If you sit behind Cloudflare or a WAF, ask the provider's support for their IP list and allow it on this route.

Apply status changes safely

The provider's payment status table lists nine statuses: new, waiting, paying, paid, manual_accept, underpaid, refunding, refunded and expired. Two rules decide how we react to them. Only paid and manual_accept ever mark an order as paid. And a status may only move a payment forward, so a late paying callback can never undo a paid one.

We encode the second rule as a transition table on the model:

// app/Models/Payment.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Payment extends Model { protected $guarded = []; protected $casts = [ 'last_payload' => 'array', 'expires_at' => 'datetime', 'paid_at' => 'datetime', ]; public const SETTLED = ['paid', 'manual_accept']; private const TRANSITIONS = [ 'new' => ['waiting', 'paying', 'paid', 'underpaid', 'expired'], 'waiting' => ['paying', 'paid', 'underpaid', 'expired'], 'paying' => ['paid', 'underpaid', 'expired'], 'underpaid' => ['paid', 'manual_accept', 'expired'], 'expired' => ['paid', 'manual_accept'], 'paid' => ['refunding', 'refunded'], 'manual_accept' => ['refunding', 'refunded'], 'refunding' => ['refunded'], 'refunded' => [], ]; public function order(): BelongsTo { return $this->belongsTo(Order::class); } public function canMoveTo(string $next): bool { return in_array($next, self::TRANSITIONS[$this->status] ?? [], true); } } 

Notice that expired can still move to paid. Blockchain payments don't respect your timers, and if money shows up against an invoice you've already written off, you want the order to reflect it and a human to look at it, not a silent drop.

The job does the actual work inside a database transaction, with row locks so two workers can't process the same payment at once:

// app/Jobs/ProcessPaymentEvent.php namespace App\Jobs; use App\Events\OrderPaid; use App\Models\Payment; use App\Models\PaymentEvent; use Brick\Math\BigDecimal; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class ProcessPaymentEvent implements ShouldQueue { use Queueable; public int $tries = 5; public array $backoff = [10, 60, 300]; public function __construct(public int $eventId) {} public function handle(): void { DB::transaction(function () { $event = PaymentEvent::whereKey($this->eventId)->lockForUpdate()->firstOrFail(); if ($event->processed_at) { return; } $payload = $event->payload; $payment = $this->findPayment($event->track_id, $payload); if (! $payment) { Log::error('Payment event for unknown payment', ['event_id' => $event->id]); return; // left unprocessed so it shows up in review } $next = $event->status; if ($next === $payment->status || ! $payment->canMoveTo($next)) { // Duplicate or out-of-order delivery, e.g. 'paying' after 'paid'. $event->update(['processed_at' => now()]); return; } $settled = in_array($next, Payment::SETTLED, true); if ($settled && ! $this->matchesWhatWeAskedFor($payment, $payload)) { Log::critical('Paid webhook does not match local payment', [ 'payment_id' => $payment->id, 'event_id' => $event->id, ]); return; // do not fulfil; leave for manual review } $payment->update([ 'status' => $next, 'last_payload' => $payload, 'paid_at' => $settled ? ($payment->paid_at ?? now()) : $payment->paid_at, ]); $order = $payment->order()->lockForUpdate()->first(); if ($settled && $order->status === 'pending') { $order->update(['status' => 'paid']); OrderPaid::dispatch($order); } $event->update(['processed_at' => now()]); }); } private function findPayment(string $trackId, array $payload): ?Payment { $payment = Payment::where('track_id', $trackId)->lockForUpdate()->first(); // The invoice was created but our request timed out before we saved // the track_id. order_id carries our payment ID, so we can claim it. if (! $payment && isset($payload['order_id'])) { $payment = Payment::whereKey($payload['order_id']) ->whereNull('track_id') ->lockForUpdate() ->first(); $payment?->update(['track_id' => $trackId]); } return $payment; } private function matchesWhatWeAskedFor(Payment $payment, array $payload): bool { return (string) ($payload['order_id'] ?? '') === (string) $payment->id && strtoupper((string) ($payload['currency'] ?? '')) ===Source:https://dev.to/kevins1988/how-to-accept-usdt-payments-in-laravel-checkout-webhooks-and-order-updates-31g9

Related posts