beziworld / laravel-tpay
Laravel integration for the Tpay OpenAPI: transactions, refunds, BLIK and JWS-signed notifications.
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 16:36:49 UTC
README
Laravel integration for the Tpay OpenAPI: transactions, BLIK, refunds and notifications verified with both the md5 checksum and the JWS signature.
Features
- OAuth access token fetched on demand, cached and refreshed automatically after a 401
- Transactions with redirect to the payment page, a preselected bank or BLIK level 0
- Refunds (full or partial), cancellation, repeated payment attempts (
pay,payWithBlik) - Bank groups and channels for your own payment method picker
- Notification endpoint verifying the md5sum and the RS256 JWS signature against the Tpay certificate chain,
answering
TRUEand dispatchingTransactionPaid/TransactionChargedBack - Generic
Tpay::call()for endpoints without a dedicated method - Sandbox and production, PHP 8.3+, Laravel 12 and 13, PHPStan level 8
Installation
composer require beziworld/laravel-tpay
php artisan vendor:publish --tag=tpay-config # optional
TPAY_CLIENT_ID=01G...-xxxx TPAY_CLIENT_SECRET=secret TPAY_SECURITY_CODE=notification-security-code # Merchant panel > Settings > Notifications TPAY_MERCHANT_ID=12345 # optional, rejects notifications for other accounts TPAY_SANDBOX=true TPAY_SUCCESS_URL=https://shop.test/payment/success TPAY_ERROR_URL=https://shop.test/payment/error
OpenAPI keys are generated in the merchant panel (Integration > API). Sandbox accounts can be created at register.sandbox.tpay.com.
Payment flow
1. Create the transaction
use Beziworld\Tpay\Data\TransactionRequest; use Beziworld\Tpay\Exceptions\ApiException; use Beziworld\Tpay\Facades\Tpay; public function pay(Order $order): RedirectResponse { try { $transaction = Tpay::createTransaction(new TransactionRequest( amount: $order->total, // "123.45", 123.45 or 123 are all accepted description: "Order {$order->number}", payerEmail: $order->email, payerName: $order->customer_name, hiddenDescription: (string) $order->id, // returned in notifications as tr_crc )); } catch (ApiException $e) { report($e); return back()->withErrors('The payment could not be started.'); } $order->update(['tpay_transaction_id' => $transaction->transactionId, 'tpay_title' => $transaction->title]); return redirect()->away($transaction->paymentUrl); }
Options: groupId (preselected bank from Tpay::bankGroups()), channelId, lang, successUrl, errorUrl,
notificationUrl, payer (phone, address, taxId, ip ...), or a raw pay object.
BLIK level 0 (code entered in your shop)
$transaction = Tpay::createTransaction(new TransactionRequest( amount: '49.99', description: 'Subscription', payerEmail: $user->email, payerName: $user->name, hiddenDescription: "sub-{$subscription->id}", blikCode: $request->input('blik_code'), // six digits )); // The customer confirms in the banking app; the result arrives as a notification. // Wrong code? Retry on the same transaction: Tpay::payWithBlik($transaction->transactionId, $newCode);
2. Handle the notification
The package registers POST /tpay/notification and passes its URL in callbacks.notification.url. Tpay uses it
only when Allow override is enabled in the merchant panel; otherwise set the same URL in the panel.
use Beziworld\Tpay\Events\TransactionPaid; class MarkOrderAsPaid { public function handle(TransactionPaid $event): void { $notification = $event->notification; $order = Order::findOrFail($notification->crc); // hiddenDescription if ($order->isPaid()) { return; // notifications can be repeated } if (bccomp($notification->paid, (string) $order->total, 2) !== 0) { Log::alert('Tpay amount mismatch', ['order' => $order->id]); return; } $order->markAsPaid(tpayTitle: $notification->transactionTitle); } }
Other events: TransactionChargedBack (full refund) and NotificationReceived (every authenticated notification).
Optional fields such as card_token or tr_channel are available through $notification->get('card_token').
Refunds and other calls
Tpay::refund($order->tpay_transaction_id); // full refund Tpay::refund($order->tpay_transaction_id, '20.00'); // partial refund Tpay::cancel($transactionId); // cancel a pending transaction Tpay::transaction($transactionId)->isPaid(); // status check Tpay::bankGroups(onlyOnline: true); // list<BankGroup> with logos Tpay::channels(); // list<PaymentChannel> with constraints Tpay::call('GET', '/refunds'); // any other OpenAPI endpoint
Security
- md5sum is checked with
TPAY_SECURITY_CODE. Without the code every notification is rejected unless you disable the check explicitly (TPAY_VERIFY_MD5=false, not recommended). - JWS: the
X-JWS-Signatureheader is verified with RS256 over the raw request body. The signing certificate must be served fromhttps://secure.tpay.com/(or the sandbox origin), must be issued by the Tpay root CA and is cached forTPAY_CERTIFICATE_TTLseconds; a failed check refreshes the certificates once, so key rotation is handled. - The route uses the
apimiddleware group (no CSRF). Rejected notifications get HTTP 400 andFALSE - <reason>. - The md5sum does not cover
tr_statusortr_paid; only the JWS signature protects the whole body. KeepTPAY_VERIFY_JWS=truein production, otherwise a captured notification could be replayed with a changed status. - Certificates are re-downloaded after a failed verification at most once every 5 minutes, and the root CA is never evicted, so forged requests cannot flood Tpay with certificate downloads. Expired certificates are rejected.
- With
TPAY_SANDBOX=false, notifications flaggedtest_mode=1are acknowledged but do not dispatchTransactionPaid. - Tpay repeats notifications until it receives
TRUE, so make listeners idempotent (key ontr_id). - When transactions are created from queued jobs or console commands, the notification URL is generated from
APP_URL. SetAPP_URLcorrectly or configureTPAY_NOTIFICATION_URLexplicitly. - Creating transactions, refunds and payments are never retried automatically after a timeout.
Exceptions
All exceptions extend TpayException: ApiException (with httpStatus(), errors() for field level errors and
payload()), InvalidNotification, InvalidConfiguration.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues and feature requests: GitHub issues
- Security vulnerabilities: see SECURITY.md
Need help integrating payments in your shop or a custom Laravel project? Get in touch through beziworld.eu.
Other packages by the author
- beziworld/laravel-przelewy24: Przelewy24 payments
- beziworld/laravel-smsapi: SMSAPI messages and notifications
- beziworld/laravel-directadmin: DirectAdmin hosting panel API
License
MIT. See LICENSE.md. This package is not affiliated with Krajowy Integrator Płatności S.A. (Tpay).