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:
- The customer places an order. Your app creates a local
paymentsrow before talking to the provider. - Your backend creates an invoice and stores the returned
track_idand payment URL. - The customer is redirected to the hosted payment page, picks a network and sends USDT.
- The provider sends a signed webhook when the status changes, first
paying, laterpaid. - Your webhook route verifies the signature, stores the event and returns
okimmediately. - A queued job applies the status change to the payment, and to the order only when the payment is actually
paid. - 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'] ?? '')) === $payment->currency
&& BigDecimal::of((string) ($payload['amount'] ?? '0'))->isEqualTo($payment->amount);
}
}
Three details are worth calling out.
The order row is locked and re-checked before it changes. Without the pending check, two different paid payments for the same order, say the customer paid an old invoice and a new one, would fire OrderPaid twice. With it, the second payment is still recorded as paid, and you have a clean signal that this customer needs a refund.
OrderPaid is where fulfilment hangs: sending the license key, activating the plan, notifying the warehouse. Make the event class implement ShouldDispatchAfterCommit. Otherwise a listener can run, send an email, and then the transaction rolls back, leaving a customer with a product your database says they never paid for.
The amount check compares the invoice amount in the payload with what we requested, using BigDecimal so decimals compare exactly. brick/math is already a Laravel dependency, so there's nothing to install, and unlike bccomp it doesn't need the bcmath extension, which plenty of slim PHP images leave out. A mismatch at this point shouldn't happen, since the payload is signed. If it ever does, it points to a bug in your own mapping, and stopping for review is cheaper than shipping the wrong order.
The remaining statuses mostly change what the customer sees. The order page reads from your database, never from the provider in real time:
| Payment status | What the order page shows | What your app does |
|---|---|---|
new, waiting
|
Waiting for payment, with a link back to the invoice | Nothing yet |
paying |
Payment detected, waiting for confirmation | Nothing yet. Do not fulfil |
paid, manual_accept
|
Payment confirmed | Order marked paid, OrderPaid dispatched |
underpaid |
Partial payment received | Notify the customer and support |
expired |
Invoice expired, with a button to try again | Order stays pending; the pay route creates a new invoice |
refunding, refunded
|
Refund in progress or completed | Revoke access if your product requires it |
Catch what the webhook missed
Webhooks are the fast path, not the guarantee. A deploy that takes the route down for an afternoon, an expired TLS certificate, a firewall rule someone tightened: any of these can outlast the five retry attempts, and the payment then sits as waiting in your database while the customer's money has arrived.
The fix is a scheduled command that asks the provider directly about payments that are still open. The Payment Information endpoint returns the current state of one invoice by track_id, in the same shape as the webhook body. That means the result can be fed through the exact same job, and there's only one code path that changes payment state.
// app/Console/Commands/ReconcilePayments.php
namespace App\Console\Commands;
use App\Jobs\ProcessPaymentEvent;
use App\Models\Payment;
use App\Models\PaymentEvent;
use Illuminate\Console\Command;
use OxaPay\Laravel\Exceptions\OxaPayException;
use OxaPay\Laravel\Exceptions\RateLimitException;
use OxaPay\Laravel\Support\Facades\OxaPay;
class ReconcilePayments extends Command
{
protected $signature = 'payments:reconcile';
protected $description = 'Ask the provider about payments that are still open';
public function handle(): int
{
Payment::query()
->whereNotNull('track_id')
->whereIn('status', ['new', 'waiting', 'paying', 'underpaid'])
->where('updated_at', '<', now()->subMinutes(10))
->where('created_at', '>', now()->subDays(3))
->chunkById(50, function ($payments) {
foreach ($payments as $payment) {
try {
$info = OxaPay::payment()->information($payment->track_id);
} catch (RateLimitException) {
return false; // stop here; the next run continues
} catch (OxaPayException $e) {
$this->warn("{$payment->track_id}: {$e->getMessage()}");
continue;
}
$status = strtolower((string) ($info['status'] ?? ''));
if ($status === '' || $status === $payment->status) {
$payment->touch();
continue;
}
$event = PaymentEvent::createOrFirst(
['payload_hash' => hash('sha256', "reconcile|{$payment->track_id}|{$status}")],
[
'track_id' => $payment->track_id,
'status' => $status,
'source' => 'reconcile',
'payload' => $info,
],
);
if ($event->wasRecentlyCreated) {
ProcessPaymentEvent::dispatch($event->id);
}
}
});
return self::SUCCESS;
}
}
Schedule it in routes/console.php, with use Illuminate\Support\Facades\Schedule; at the top:
Schedule::command('payments:reconcile')->everyTenMinutes()->withoutOverlapping();
The query is intentionally narrow. It only looks at payments that haven't changed in ten minutes, so it doesn't race with webhooks that are on their way, and it stops looking after three days, by which point an open invoice is long expired. touch() on unchanged rows pushes them back by another ten minutes, which keeps the command from asking about the same stuck invoice on every run. On a rate-limit response it simply stops; the next run picks up where this one left off.
There is one case this command can't see: rows whose track_id was never saved because the invoice request timed out. For those, a nightly job can pull the Payment History for the previous day's date window and match results to orphaned rows by order_id. The history endpoint has no order_id filter, so filter by date and match in your code. In most apps this list is empty most nights, which is exactly what you want to confirm.
Test it, then go live
Most of what can go wrong here is about ordering and repetition, and that's easy to test without any network at all. Because the signature is just an HMAC of the body, a feature test can sign its own payloads:
// tests/Feature/OxaPayWebhookTest.php
namespace Tests\Feature;
use App\Events\OrderPaid;
use App\Models\Order;
use App\Models\Payment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class OxaPayWebhookTest extends TestCase
{
use RefreshDatabase;
private Payment $payment;
protected function setUp(): void
{
parent::setUp();
config(['oxapay.merchants.default' => 'test-merchant-key']);
$order = Order::create(['status' => 'pending', 'total' => '49.99']);
$this->payment = $order->payments()->create([
'track_id' => '184747701',
'status' => 'waiting',
'amount' => '49.99',
'currency' => 'USDT',
]);
}
private function webhook(string $status, string $key = 'test-merchant-key')
{
$body = json_encode([
'type' => 'invoice',
'track_id' => '184747701',
'status' => $status,
'order_id' => (string) $this->payment->id,
'amount' => 49.99,
'currency' => 'USDT',
]);
return $this->call('POST', '/webhooks/oxapay', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_HMAC' => hash_hmac('sha512', $body, $key),
], $body);
}
public function test_a_duplicate_paid_webhook_fulfils_the_order_once(): void
{
Event::fake([OrderPaid::class]);
$this->webhook('paid')->assertOk()->assertSee('ok');
$this->webhook('paid')->assertOk();
$this->assertSame('paid', $this->payment->fresh()->status);
$this->assertSame('paid', $this->payment->order->fresh()->status);
Event::assertDispatchedTimes(OrderPaid::class, 1);
}
public function test_a_late_paying_webhook_does_not_undo_paid(): void
{
$this->webhook('paid');
$this->webhook('paying');
$this->assertSame('paid', $this->payment->fresh()->status);
}
public function test_a_bad_signature_changes_nothing(): void
{
$this->webhook('paid', key: 'wrong-key')->assertStatus(400);
$this->assertSame('waiting', $this->payment->fresh()->status);
$this->assertDatabaseCount('payment_events', 0);
}
}
The default test queue connection is sync, so the job runs inside the request and the assertions see its effects immediately. The same approach covers the other two paths worth pinning down: a webhook for a payment whose track_id was never saved, and a reconcile run against a faked lookup response. The SDK uses Laravel's HTTP client, so Http::fake() intercepts its calls without any extra setup.
For an end-to-end run, keep OXAPAY_SANDBOX=true and expose your local app, since callbacks are never delivered to localhost:
php artisan serve
ngrok http 8000
Put the ngrok HTTPS URL plus /webhooks/oxapay in OXAPAY_PAYMENT_CALLBACK_URL, start a queue worker with php artisan queue:work, create an order and complete a test payment. Then check three places: the payment_events table, the payment row, and the order. If you've never seen a real callback body from your account, this is also the moment to look at one and confirm the field names your code reads.
Before switching to production, go through this list:
-
OXAPAY_SANDBOX=falseand the live merchant key in.env - Callback URL on your public HTTPS domain, reachable from outside your network
- WAF or CDN rules allow the provider's IPs on
/webhooks/oxapay - A queue worker running under Supervisor or Horizon, restarted on deploy
- The scheduler cron entry (
* * * * * php artisan schedule:run) in place - An alert for payment events left unprocessed for more than 15 minutes
-
lifetime,under_paid_coverageandfee_paid_by_payeragreed with whoever owns pricing
That's the whole integration: two tables, one controller to start payments, one to receive webhooks, one job that owns every state change and one command that keeps it honest. Each piece is small, and what makes it reliable is the split between them. The provider handles wallets, chains and confirmations; your code only has to agree with it about what happened, once. The rest of the API, from white-label payments to payouts, is covered in the OxaPay documentation.
Top comments (0)