payment_orders
Unique client_txn_id and gateway_order_id; amount_paise as an integer; explicit status.
PHP · Laravel · queues
Keep credentials in configuration, create orders from trusted models, verify raw HMAC webhooks, and use database uniqueness plus a durable queue boundary to make duplicate delivery harmless.
Data model first
Unique client_txn_id and gateway_order_id; amount_paise as an integer; explicit status.
Unique deterministic event_key, mapped order ID, exact verified payload, received time.
Unique fulfilment key, topic, payload, attempts, available time, processed time.
Tenant/customer ownership, trusted catalogue total, and an explicit paid transition.
Step 1 · configuration
Laravel’s cached configuration should receive secrets from the runtime environment. Use the example values only in local development and clear any real value from shell history and logs.
// config/services.php
return [
// Other service configuration...
'vyapargateway' => [
'base_url' => env(
'VYAPARGATEWAY_BASE_URL',
'https://vyapargateway.com'
),
'api_key' => env('VYAPARGATEWAY_API_KEY'),
'webhook_secret' => env('VYAPARGATEWAY_WEBHOOK_SECRET'),
'public_store_url' => env('APP_URL'),
],
];
// .env — local development values; never commit real credentials
VYAPARGATEWAY_BASE_URL=https://vyapargateway.com
VYAPARGATEWAY_API_KEY=replace-with-local-secret
VYAPARGATEWAY_WEBHOOK_SECRET=replace-with-local-secret Step 2 · service
Keep amounts as integer paise and format a two-decimal rupee string at the request boundary. Do not automatically retry a create call with a new client ID; a connection failure can occur after the gateway accepted the order.
<?php
namespace App\Payments;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class VyaparGateway
{
public function createOrder(
string $clientTxnId,
int $amountPaise,
string $description,
array $customer,
): array {
$response = Http::baseUrl($this->baseUrl())
->acceptJson()
->asJson()
->withHeaders(['X-API-Key' => $this->apiKey()])
->connectTimeout(3)
->timeout(10)
->post('/api/v1/create_order', [
'client_txn_id' => $clientTxnId,
'amount' => $this->paiseToRupees($amountPaise),
'p_info' => $description,
'customer_name' => Arr::get($customer, 'name'),
'customer_email' => Arr::get($customer, 'email'),
'customer_mobile' => Arr::get($customer, 'mobile'),
'redirect_url' => url(
'/orders/' . rawurlencode($clientTxnId)
),
'callback_url' => url(
'/api/webhooks/vyapargateway'
),
]);
$payload = $response->json();
$data = is_array($payload) ? ($payload['data'] ?? null) : null;
if (
$response->failed()
|| ($payload['status'] ?? false) !== true
|| !is_array($data)
) {
throw new RuntimeException(
(string) (
$payload['detail']
?? $payload['msg']
?? 'Gateway rejected the order'
)
);
}
if (
($data['client_txn_id'] ?? '') !== $clientTxnId
|| ($data['currency'] ?? '') !== 'INR'
) {
throw new RuntimeException(
'Gateway response does not match the local order'
);
}
return $data;
}
public function checkOrderStatus(
?string $orderId,
?string $clientTxnId,
): array {
if (!$orderId && !$clientTxnId) {
throw new RuntimeException('An order identifier is required');
}
$response = Http::baseUrl($this->baseUrl())
->acceptJson()
->asJson()
->withHeaders(['X-API-Key' => $this->apiKey()])
->connectTimeout(3)
->timeout(10)
->post('/api/v1/check_order_status', [
'order_id' => $orderId,
'client_txn_id' => $clientTxnId,
]);
$payload = $response->json();
if (
$response->failed()
|| ($payload['status'] ?? false) !== true
|| !is_array($payload['data'] ?? null)
) {
throw new RuntimeException(
(string) ($payload['msg'] ?? 'Status check failed')
);
}
return $payload['data'];
}
private function paiseToRupees(int $paise): string
{
if ($paise <= 0) {
throw new RuntimeException('Amount must be positive');
}
return intdiv($paise, 100)
. '.'
. str_pad((string) ($paise % 100), 2, '0', STR_PAD_LEFT);
}
private function baseUrl(): string
{
$value = (string) config('services.vyapargateway.base_url');
if ($value === '') {
throw new RuntimeException('Gateway base URL is missing');
}
return rtrim($value, '/');
}
private function apiKey(): string
{
$value = (string) config('services.vyapargateway.api_key');
if ($value === '') {
throw new RuntimeException('Gateway API key is missing');
}
return $value;
}
}
Laravel HTTP client reference →
Step 3 · checkout
The authenticated relationship query enforces ownership.
CartPricing is the application boundary that reads
trusted catalogue prices; the browser supplies only the cart ID.
Database unique indexes make concurrent retries converge.
<?php
namespace App\Http\Controllers;
use App\Models\PaymentOrder;
use App\Payments\VyaparGateway;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
final class CheckoutController
{
public function __invoke(
Request $request,
VyaparGateway $gateway,
): JsonResponse {
$input = $request->validate([
'cart_id' => ['required', 'uuid'],
]);
$customer = $request->user();
$cart = $customer->carts()
->whereKey($input['cart_id'])
->firstOrFail();
$amountPaise = app(CartPricing::class)
->totalPaiseFromCatalogue($cart);
$order = PaymentOrder::query()->firstOrCreate(
[
'customer_id' => $customer->getKey(),
'cart_id' => $cart->getKey(),
],
[
'client_txn_id' => 'ORD-' . Str::uuid(),
'amount_paise' => $amountPaise,
'currency' => 'INR',
'status' => 'pending',
],
);
abort_unless(
$order->amount_paise === $amountPaise
&& $order->status === 'pending',
409,
'Cart already has a different payment state',
);
try {
$remote = $gateway->createOrder(
clientTxnId: $order->client_txn_id,
amountPaise: $order->amount_paise,
description: 'Order ' . $order->public_number,
customer: [
'name' => $customer->name,
'email' => $customer->email,
'mobile' => $customer->mobile,
],
);
DB::transaction(function () use ($order, $remote): void {
$locked = PaymentOrder::query()
->lockForUpdate()
->findOrFail($order->getKey());
$locked->gateway_order_id = $remote['order_id'];
$locked->payment_url = $remote['payment_url'];
$locked->expires_at = $remote['expires_at'];
$locked->save();
});
} catch (Throwable $exception) {
report($exception);
abort(502, 'Payment checkout is temporarily unavailable');
}
return response()->json([
'order_id' => $order->public_number,
'payment_url' => $remote['payment_url'],
'expires_at' => $remote['expires_at'],
], 201);
}
} checkOrderStatus() using that row’s IDs before the
customer is offered a genuine new attempt.
Step 4 · route
Register the callback under API routes. Do not disable CSRF globally, and do not protect the webhook with browser-session authentication: its timestamped signature is its authentication.
// routes/api.php
use App\Http\Controllers\VyaparGatewayWebhookController;
use Illuminate\Support\Facades\Route;
Route::post(
'/webhooks/vyapargateway',
VyaparGatewayWebhookController::class,
)->name('webhooks.vyapargateway');
// API routes are outside the web CSRF middleware group by default.
// If your application registers this URI in routes/web.php, add only this
// exact URI to Laravel's documented CSRF exclusion configuration. Step 5 · webhook transaction
The expected digest signs
timestamp + "." + exact raw JSON body. Verification
happens before decoding. The unique event, order transition, and
outbox write occur in one transaction so a process crash cannot
separate payment truth from future fulfilment.
<?php
namespace App\Http\Controllers;
use App\Models\OutboxMessage;
use App\Models\PaymentEvent;
use App\Models\PaymentOrder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use JsonException;
final class VyaparGatewayWebhookController
{
public function __invoke(Request $request): Response
{
$rawBody = $request->getContent();
$timestamp = (string) $request->header(
'X-VyaparGateway-Timestamp',
''
);
$signature = strtolower((string) $request->header(
'X-VyaparGateway-Signature',
''
));
if (
!preg_match('/^\d{10}$/', $timestamp)
|| abs(time() - (int) $timestamp) > 300
) {
abort(401, 'Invalid webhook timestamp');
}
$secret = (string) config(
'services.vyapargateway.webhook_secret'
);
abort_if($secret === '', 500, 'Webhook secret is not configured');
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $rawBody,
$secret,
);
if (
!preg_match('/^[a-f0-9]{64}$/', $signature)
|| !hash_equals($expected, $signature)
) {
abort(401, 'Invalid webhook signature');
}
try {
$event = json_decode(
$rawBody,
true,
32,
JSON_THROW_ON_ERROR,
);
} catch (JsonException) {
abort(400, 'Invalid JSON');
}
foreach (
['event', 'order_id', 'client_txn_id', 'status', 'amount', 'currency']
as $required
) {
abort_unless(array_key_exists($required, $event), 400);
}
DB::transaction(function () use ($event, $rawBody): void {
$order = PaymentOrder::query()
->where('client_txn_id', $event['client_txn_id'])
->lockForUpdate()
->firstOrFail();
$eventKey = implode(':', [
$event['event'],
$event['order_id'],
$event['upi_txn_id'] ?? $event['status'],
]);
$paymentEvent = PaymentEvent::query()->firstOrCreate(
['event_key' => $eventKey],
[
'payment_order_id' => $order->getKey(),
'payload' => $rawBody,
],
);
if (!$paymentEvent->wasRecentlyCreated) {
return;
}
abort_unless(
hash_equals(
(string) $order->gateway_order_id,
(string) $event['order_id'],
)
&& $event['currency'] === 'INR'
&& $this->rupeesToPaise((string) $event['amount'])
=== $order->amount_paise,
409,
'Payment does not match order',
);
if ($event['status'] === 'success') {
if ($order->status === 'pending') {
$order->status = 'paid';
$order->upi_txn_id = $event['upi_txn_id'] ?? null;
$order->paid_at = now();
$order->save();
}
OutboxMessage::query()->firstOrCreate(
['key' => 'fulfil-order:' . $order->getKey()],
[
'topic' => 'order.paid',
'payload' => ['order_id' => $order->getKey()],
],
);
} elseif (in_array(
$event['status'],
['failed', 'expired'],
true,
)) {
$order->gateway_status = $event['status'];
$order->save();
} else {
abort(400, 'Unsupported payment status');
}
});
return response()->noContent();
}
private function rupeesToPaise(string $amount): int
{
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $amount)) {
abort(400, 'Invalid payment amount');
}
[$whole, $fraction] = array_pad(explode('.', $amount, 2), 2, '');
$fraction = str_pad($fraction, 2, '0');
return ((int) $whole * 100) + (int) $fraction;
}
}
PHP hash_equals reference →
Operational controls
Add database uniqueness for client transaction, gateway order, payment event, and outbox keys.
Claim outbox rows safely, dispatch a unique queued job, and mark processed only after success.
Schedule bounded reconciliation for ambiguous and overdue pending orders with backoff.
Disable request-body logging on sensitive routes and redact API and signature headers.
Test duplicate, concurrent, delayed, stale, malformed, amount-mismatch, and wrong-order events.
Reconcile successful transactions with provider or bank reports; payment success is not settlement.
Questions
Unique keys, row locks, and an outbox keep payment state consistent through retries.