mariandumitru / netopay
Laravel package for NETOPIA Payment API integration
Fund package maintenance!
Requires
- php: ^8.5
- illuminate/contracts: ^12.69 || ^13.30
- illuminate/support: ^12.69 || ^13.30
- spatie/laravel-package-tools: ^1.93.3
Requires (Dev)
- larastan/larastan: ^3.14
- laravel/pint: ^1.32
- nunomaduro/collision: ^8.9.5
- orchestra/testbench: ^10.12 || ^11.3
- pestphp/pest: ^4.7 || ^5.3
- pestphp/pest-plugin-arch: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.1 || ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0.5
- phpstan/phpstan-phpunit: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 22:39:46 UTC
README
A Laravel package for integrating with the NETOPIA Payments API. Supports hosted-page card payments, recurring payments via saved card tokens, IPN webhook handling, 3DS authentication, and payment status retrieval.
The package handles all HTTP communication with Netopia and fires Laravel events your application listens to — keeping your business logic completely separate from the payment protocol.
Quick start
The happy path from composer require to a working sandbox payment, in five steps. Each step has a full section further down.
1. Install and publish the config
composer require mariandumitru/netopay php artisan vendor:publish --tag=netopay-config
2. Set the credentials in .env (sandbox is the default):
NETOPIA_API_KEY_SANDBOX=your-real-sandbox-api-key NETOPIA_POS_SIGNATURE_SANDBOX=your-real-sandbox-pos-signature NETOPIA_AFTER_PAYMENT_REDIRECT=/dashboard
3. Exclude the package routes from CSRF in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['netopia/ipn', 'netopia/return']); })
4. Start a payment from your own controller:
use MarianDumitru\Netopay\Facades\Netopay; $response = Netopay::start($orderData); return redirect($response->paymentUrl); // see XHR note in section 1 below if you're on Inertia/Livewire
5. Listen for the outcome in App\Providers\AppServiceProvider::boot():
use Illuminate\Support\Facades\Event; use MarianDumitru\Netopay\Events\NetopiaPaymentApproved; Event::listen(NetopiaPaymentApproved::class, function ($event) { // fulfil the order — see "Handling payment outcomes" for the full pattern });
That's the whole picture. The rest of this README expands on each step (building $orderData, the double-fulfilment guard, 3DS, recurring payments, testing).
Requirements
- PHP 8.5+
- Laravel 12.69+ or 13.30+
The test suite passes on PHP 8.5 against both Laravel 13 (Pest 5, Testbench 11) and Laravel 12 (Pest 4, Testbench 10). If you hit an issue, please open an issue.
Installation
composer require mariandumitru/netopay
The service provider and Netopay facade are registered automatically via Laravel package discovery.
Publish the configuration file:
php artisan vendor:publish --tag=netopay-config
Configuration
Add the following to your .env file:
NETOPIA_SANDBOX=true # Sandbox credentials NETOPIA_API_KEY_SANDBOX=your-sandbox-api-key NETOPIA_POS_SIGNATURE_SANDBOX=your-sandbox-pos-signature # Live credentials NETOPIA_API_KEY_LIVE=your-live-api-key NETOPIA_POS_SIGNATURE_LIVE=your-live-pos-signature # Optional — if unset, the package auto-resolves these to its own routes # (named `netopia.ipn` and `netopia.return`). Set them explicitly only if # you've disabled the package routes or you want to override the URLs. # NETOPIA_NOTIFY_URL=https://yourdomain.com/netopia/ipn # NETOPIA_REDIRECT_URL=https://yourdomain.com/netopia/return # Where your app redirects the user after processing the return NETOPIA_AFTER_PAYMENT_REDIRECT=/dashboard # Optional — payment defaults # NETOPIA_CURRENCY=RON # NETOPIA_LANGUAGE=ro # NETOPIA_EMAIL_TEMPLATE=confirm
Set NETOPIA_SANDBOX=false in production. The package automatically switches API keys and endpoints based on this value.
Full environment variable reference
| Variable | Default | Description |
|---|---|---|
NETOPIA_SANDBOX |
true |
Switches all credentials and endpoints between sandbox and live. |
NETOPIA_API_KEY_SANDBOX |
— | Sandbox API key. |
NETOPIA_POS_SIGNATURE_SANDBOX |
falls back to NETOPIA_SALES_POINT_KEY |
Sandbox POS signature. |
NETOPIA_API_KEY_LIVE |
— | Live API key. |
NETOPIA_POS_SIGNATURE_LIVE |
falls back to NETOPIA_SALES_POINT_KEY |
Live POS signature. |
NETOPIA_SALES_POINT_KEY |
— | Shared fallback used by both NETOPIA_POS_SIGNATURE_SANDBOX and NETOPIA_POS_SIGNATURE_LIVE when those are unset. Useful when migrating from older integrations, but be aware it applies the same value to both environments. |
NETOPIA_NOTIFY_URL |
auto → route('netopia.ipn') |
URL Netopia POSTs the IPN to. Optional when package routes are enabled. |
NETOPIA_REDIRECT_URL |
auto → route('netopia.return') |
URL Netopia redirects the user to after payment. Optional when package routes are enabled. Override it for a single payment with OrderDto::$redirectUrl. |
NETOPIA_AFTER_PAYMENT_REDIRECT |
/ |
Where the package's return controller redirects the user after firing NetopiaReturnReceived. |
NETOPIA_CURRENCY |
RON |
Fallback currency when Netopia's response omits one. |
NETOPIA_LANGUAGE |
ro |
Language of the hosted payment page Netopia shows the customer (ro, en, …). |
NETOPIA_EMAIL_TEMPLATE |
confirm |
Email template identifier sent to Netopia on payment start. |
NETOPIA_API_URL_SANDBOX |
sandbox /payment/card/start URL |
Override the sandbox start endpoint (rarely needed). |
NETOPIA_STATUS_URL_SANDBOX |
sandbox /operation/status URL |
Override the sandbox status endpoint. |
NETOPIA_VERIFY_AUTH_URL_SANDBOX |
sandbox /payment/card/verify-auth URL |
Override the sandbox 3DS verify endpoint. |
NETOPIA_API_URL_LIVE |
live /payment/card/start URL |
Override the live start endpoint. |
NETOPIA_STATUS_URL_LIVE |
live /operation/status URL |
Override the live status endpoint. |
NETOPIA_VERIFY_AUTH_URL_LIVE |
live /payment/card/verify-auth URL |
Override the live 3DS verify endpoint. |
About
NETOPIA_NOTIFY_URL/NETOPIA_REDIRECT_URL. If you set these explicitly, they must point at the package's actual webhook routes —<your-app-url>/netopia/ipnand<your-app-url>/netopia/returnby default. If you changedroutes.prefixinconfig/netopay.php(e.g. topayments), the URLs become<your-app-url>/payments/ipnand<your-app-url>/payments/return. The endpoint paths inside the prefix (/ipn,/return) are fixed by the package.
Routes
The package registers two routes automatically:
| Method | URI | Description |
|---|---|---|
POST |
/netopia/ipn |
Netopia IPN webhook |
GET|POST |
/netopia/return |
User return after payment |
Important: Exclude these routes from CSRF verification in your
bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'netopia/ipn', 'netopia/return', ]); })
You can disable the package routes entirely and register your own:
// config/netopay.php 'routes' => [ 'enabled' => false, 'prefix' => 'netopia', 'middleware' => [], ],
Heads up: when package routes are disabled, the auto-resolution of
NETOPIA_NOTIFY_URL/NETOPIA_REDIRECT_URLrelies on the named routesnetopia.ipnandnetopia.return. Either preserve those route names on your own routes, or set both env vars explicitly — otherwiseNetopay::start()throwsRouteNotFoundException.
Usage
1. Initiating a payment
Build an OrderDto from your application's data and call Netopay::start(). This returns a StartPaymentResponseDto containing the Netopia-hosted page URL to redirect the user to.
use MarianDumitru\Netopay\Dto\BillingDto; use MarianDumitru\Netopay\Dto\OrderDto; use MarianDumitru\Netopay\Facades\Netopay; $billing = new BillingDto( email: $user->email, phone: $billingProfile->phone, firstName: $user->first_name, lastName: $user->last_name, city: $billingProfile->city, country: $billingProfile->numericCountryCode, // ISO 3166-1 numeric (e.g. 642 for Romania) state: $billingProfile->state, postalCode: $billingProfile->post_code, details: $billingProfile->full_address, ); $orderData = new OrderDto( orderId: $payment->uuid, // your unique order identifier amount: 149.99, currency: 'RON', description: 'Subscription — 2 devices (Monthly)', billing: $billing, ); $response = Netopay::start($orderData); // Persist what you'll need later (ntpID, and the 3DS authenticationToken if present) // so your return listener can look the payment up and verify 3DS. $payment->update([ 'provider_payment_id' => $response->providerPaymentId, 'payload' => [ 'start' => [ 'customerAction' => $response->customerAction, // contains authenticationToken when 3DS is required ], ], ]); // Redirect the user to Netopia's hosted payment page return redirect($response->paymentUrl);
The
payloadcolumn above is just a JSON column on yourpaymentstable — name it whatever fits your schema. The point is to keep$response->customerActionsomewhere yourNetopiaReturnReceivedlistener can read it, since 3DS verification needs theauthenticationToken.
XHR / SPA gotcha. A plain
redirect($response->paymentUrl)returns a302to the browser. If you initiate the payment via XHR (Axios, Fetch, Inertiarouter.post, Livewirewire:click), the XHR layer cannot follow a cross-origin redirect to Netopia's domain — the user appears stuck and nothing happens. Use the SPA-appropriate redirect instead:// Inertia return Inertia::location($response->paymentUrl); // Livewire (v3) return $this->redirect($response->paymentUrl, navigate: false); // Axios / Fetch — return the URL as JSON and redirect on the client return response()->json(['paymentUrl' => $response->paymentUrl]); // then on the front-end: window.location.href = data.paymentUrl;
2. Handling payment outcomes
The package fires Laravel events from its webhook controller. Register listeners in your App\Providers\AppServiceProvider::boot() method (Laravel 11+ no longer ships an EventServiceProvider):
// app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\Event; use MarianDumitru\Netopay\Events\NetopiaIpnProcessingFailed; use MarianDumitru\Netopay\Events\NetopiaPaymentApproved; use MarianDumitru\Netopay\Events\NetopiaPaymentChargeback; use MarianDumitru\Netopay\Events\NetopiaPaymentFailed; use MarianDumitru\Netopay\Events\NetopiaPaymentPending; use MarianDumitru\Netopay\Events\NetopiaPaymentRefunded; use MarianDumitru\Netopay\Events\NetopiaReturnReceived; public function boot(): void { Event::listen(NetopiaPaymentApproved::class, HandlePaymentApproved::class); Event::listen(NetopiaPaymentFailed::class, HandlePaymentFailed::class); Event::listen(NetopiaPaymentPending::class, HandlePaymentPending::class); Event::listen(NetopiaPaymentRefunded::class, HandlePaymentRefunded::class); Event::listen(NetopiaPaymentChargeback::class, HandlePaymentChargeback::class); Event::listen(NetopiaReturnReceived::class, HandleNetopiaReturn::class); Event::listen(NetopiaIpnProcessingFailed::class, ReportIpnFailure::class); }
Payment statuses
For each IPN, the package asks Netopia for the confirmed status, maps the numeric code to a PaymentStatus case, and fires one event. NETOPIA's v2 docs list only 3, 5, 12 and 15; the other codes come from the status constants in NETOPIA's official v2 PHP SDK (netopia/payment2).
PaymentStatus |
Netopia codes | Meaning | IPN event |
|---|---|---|---|
Paid |
3 | Money captured | NetopiaPaymentApproved |
Confirmed |
5 | Payment confirmed | NetopiaPaymentApproved |
Pending |
1 new, 2 opened, 6 pending, 13 fraud review, 14 pending auth, 18 pending | Not finished yet; another IPN follows | NetopiaPaymentPending |
Awaiting3DS |
15 | The customer must complete 3DS | NetopiaPaymentPending |
Failed |
11 error, 12 declined (invalid account) | Payment was refused | NetopiaPaymentFailed |
Cancelled |
4 | Payment voided; don't deliver | NetopiaPaymentFailed |
Expired |
23 | Never paid, and Netopia closed it | NetopiaPaymentFailed |
Refunded |
8 credit, 17 reversed | Money went back to the customer | NetopiaPaymentRefunded |
Chargeback |
9 initiated, 10 accepted, 16 representment | The customer disputed the payment with their bank | NetopiaPaymentChargeback |
Unknown |
anything else, including a missing status | Not a code the package maps | none (warning logged) |
NetopiaPaymentFailed covers three cases. Check $event->status->state if you treat a decline differently from a cancellation or an expiry. Every event's $status->providerStatusCode holds the raw Netopia code.
An Unknown status fires no payment event, so an unrecognised code never marks an order as failed. The package logs a warning (Netopia IPN has an unknown payment status) with the raw code instead. Codes 7 and 19–22 are SMS and subscription statuses that card payments don't use, so they map to Unknown too.
NetopiaPaymentRefunded and NetopiaPaymentChargeback
These arrive after the order was paid. A refund or chargeback is about money you already counted as received, so the order is usually marked paid (and maybe delivered) by then. Don't reject the event because the order isn't pending. Record it, and stop or reverse anything that hasn't happened yet.
A chargeback goes through stages: initiated (9), accepted (10) and representment (16). Read $event->status->providerStatusCode to tell them apart. An accepted chargeback means the money is gone.
use MarianDumitru\Netopay\Events\NetopiaPaymentRefunded; class HandlePaymentRefunded { public function handle(NetopiaPaymentRefunded $event): void { $status = $event->status; DB::transaction(function () use ($status) { $payment = Payment::where('uuid', $status->orderId) ->lockForUpdate() ->first(); if (! $payment || $payment->status === 'refunded') { return; // unknown order or IPN retry } // The order can already be 'paid' here — that's expected. $payment->update(['status' => 'refunded']); }); } }
NetopiaPaymentApproved
Fired by the IPN controller when Netopia confirms a Paid or Confirmed status. This is where you fulfil the order.
$event->status comes from Netopia's status check, which does not return the saved card. The card token, masked PAN and expiry are in the IPN body, available as $event->ipnPayload. Skip the token for one-off payments where you don't keep cards.
Double-fulfilment guard. The IPN webhook and the user-facing return redirect can both arrive within a few seconds of each other and may both run your fulfilment logic. Wrap the lookup in a transaction with
lockForUpdate()and check the status before acting, so only one of the two paths fulfils.
use Illuminate\Support\Facades\DB; use MarianDumitru\Netopay\Events\NetopiaPaymentApproved; class HandlePaymentApproved { public function handle(NetopiaPaymentApproved $event): void { $status = $event->status; // PaymentStatusDto DB::transaction(function () use ($status, $event) { $payment = Payment::where('uuid', $status->orderId) ->lockForUpdate() ->first(); if (! $payment || $payment->status === 'paid') { return; // already fulfilled by the other path } $payment->update([ 'status' => 'paid', 'provider_payment_id' => $status->providerPaymentId, 'auth_code' => $status->authCode, 'rrn' => $status->rrn, 'paid_at' => now(), ]); $token = $event->ipnPayload['payment']['token'] ?? null; if ($token) { PaymentToken::updateOrCreate( ['user_id' => $payment->user_id], [ 'token' => $token, 'pan_masked' => $event->ipnPayload['payment']['instrument']['panMasked'] ?? null, 'expire_month' => $event->ipnPayload['payment']['binding']['expireMonth'] ?? null, 'expire_year' => $event->ipnPayload['payment']['binding']['expireYear'] ?? null, ], ); } SubscriptionService::fulfil($payment); }); } }
NetopiaIpnProcessingFailed
Fired when the IPN controller cannot parse or confirm an inbound IPN (network error from Netopia, malformed payload, etc.). The controller still returns HTTP 204 to Netopia — this event is your hook to alert ops, retry via a queue, or push a breadcrumb to Sentry.
use MarianDumitru\Netopay\Events\NetopiaIpnProcessingFailed; class ReportIpnFailure { public function handle(NetopiaIpnProcessingFailed $event): void { report($event->exception); // send to Sentry / your reporter Log::warning('Netopia IPN failed', [ 'order_id' => $event->payload['order']['orderID'] ?? null, 'ntp_id' => $event->payload['payment']['ntpID'] ?? null, ]); } }
NetopiaReturnReceived
Fired when the user is redirected back to your application after completing (or abandoning) payment. Use this to look up the payment status and update your UI. For hosted-page flows, call Netopay::retrieveStatus(). For 3DS flows, call Netopay::verifyAuth() first if an auth token is present.
$event->formData is the raw POST/query payload Netopia sent on return (everything except orderId) as an array<string, mixed>. For 3DS flows, pass it straight through to Netopay::verifyAuth().
use MarianDumitru\Netopay\Enums\PaymentStatus; use MarianDumitru\Netopay\Events\NetopiaReturnReceived; use MarianDumitru\Netopay\Facades\Netopay; class HandleNetopiaReturn { public function handle(NetopiaReturnReceived $event): void { if ($event->orderId === '') { // The package already logs a warning. Decide here whether to alert or silently drop. return; } $payment = Payment::where('uuid', $event->orderId)->first(); if (! $payment) { return; } // Set during the start call — see "Initiating a payment" above $authToken = data_get($payment->payload, 'start.customerAction.authenticationToken'); if ($authToken) { // 3DS flow: verify authentication first $result = Netopay::verifyAuth( $event->orderId, $authToken, $payment->provider_payment_id, $event->formData, ); } else { // Hosted-page flow: retrieve confirmed status $result = Netopay::retrieveStatusById( $payment->provider_payment_id, $event->orderId, ); } // The customer often returns before Netopia has finished the payment, // so the state can still be Pending. Unknown means Netopia sent a code // the package doesn't recognise: don't overwrite the payment with it. if ($result->state === PaymentStatus::Unknown) { return; } $payment->update(['status' => $result->state->value]); } }
Note: The IPN webhook (
NetopiaPaymentApproved) and the return redirect (NetopiaReturnReceived) may arrive concurrently. See the double-fulfilment guard pattern in theNetopiaPaymentApprovedlistener above — apply the sameDB::transaction+lockForUpdateapproach in any code path that mutates payment state.
3. Recurring payments with a saved card token
Once a user has paid and you have saved their card token, use startWithToken(). For renewals the customer is not present for, set merchantInitiated: true on the OrderDto so the request carries scaExemptionInd: MIT. For a customer-present payment with a saved card (e.g. a returning restaurant customer), leave it false.
The card may be charged straight away, or Netopia may ask for 3DS (status 15) and return a customerAction / paymentUrl to send the customer to.
use MarianDumitru\Netopay\Enums\PaymentStatus; $orderData = new OrderDto( orderId: $payment->uuid, amount: 149.99, currency: 'RON', description: 'Renewal — Subscription #42', billing: $billing, merchantInitiated: true, ); $response = Netopay::startWithToken($orderData, $savedToken); if (in_array($response->state(), [PaymentStatus::Paid, PaymentStatus::Confirmed], true)) { $payment->update([ 'status' => $response->state()->value, 'auth_code' => $response->authCode, 'rrn' => $response->rrn, 'paid_at' => now(), ]); }
4. Retrieving payment status manually
$status = Netopay::retrieveStatusById($ntpId, $orderId); echo $status->state->value; // e.g. 'paid', 'pending', 'failed', 'refunded' — see Payment statuses echo $status->providerStatusCode; // raw Netopia code, e.g. 3 echo $status->authCode; echo $status->rrn; echo $status->paymentToken;
5. Landing the user on the just-paid resource
NETOPIA_AFTER_PAYMENT_REDIRECT is a static URL — fine for "always go to /dashboard," but most apps want to land the user on the specific resource they paid for (e.g. /orders/123). The pattern is to stash the post-payment URL in the session before redirecting to Netopia, then pull it out in your NetopiaReturnReceived listener and use it instead of the static config.
At start time (your controller, just before redirecting to Netopia):
session()->put( 'netopay.post_payment_redirect.' . $payment->uuid, route('orders.show', $order), ); return redirect($response->paymentUrl);
On return (your listener), override the package's static redirect by returning your own response. Because the package's return controller has already redirected to NETOPIA_AFTER_PAYMENT_REDIRECT by the time your listener runs, the cleanest pattern is to point NETOPIA_AFTER_PAYMENT_REDIRECT at an intermediate route in your app — /payments/landing — and resolve the real destination there:
// routes/web.php Route::get('/payments/landing', function () { // The orderId arrives via NetopiaReturnReceived; the listener can stash it on the session // under 'netopay.last_order_id' so this intermediate route knows where to go. $orderId = session()->pull('netopay.last_order_id'); $url = session()->pull("netopay.post_payment_redirect.{$orderId}", '/dashboard'); return redirect($url); });
// HandleNetopiaReturn listener public function handle(NetopiaReturnReceived $event): void { session()->put('netopay.last_order_id', $event->orderId); // ... the rest of your verifyAuth / retrieveStatus logic }
Set NETOPIA_AFTER_PAYMENT_REDIRECT=/payments/landing in .env and every payment lands the user on the right page.
6. A different return URL per payment
Pass redirectUrl on the OrderDto to send Netopia's return redirect somewhere other than NETOPIA_REDIRECT_URL for that payment only — for example a mobile WebView return route that your app handles itself:
$orderData = new OrderDto( orderId: $payment->uuid, amount: 149.99, currency: 'RON', description: 'Order #123', billing: $billing, redirectUrl: route('payments.mobile-return'), );
A custom return URL bypasses the package's return route, so NetopiaReturnReceived is not fired for that payment — your route handles the return (call verifyAuth() / retrieveStatusById() there). The IPN still goes to NETOPIA_NOTIFY_URL and fires the usual events.
Events Reference
| Event | Property | Type | Description |
|---|---|---|---|
NetopiaPaymentApproved |
$status |
PaymentStatusDto |
Payment is Paid (3) or Confirmed (5) |
$ipnPayload |
array |
Raw IPN body (card token, binding, instrument) | |
NetopiaPaymentPending |
$status |
PaymentStatusDto |
Payment is still in progress: Pending or Awaiting3DS (check $status->state) |
$ipnPayload |
array |
Raw IPN body | |
NetopiaPaymentFailed |
$status |
PaymentStatusDto |
Payment is Failed, Cancelled or Expired (check $status->state) |
$ipnPayload |
array |
Raw IPN body | |
NetopiaPaymentRefunded |
$status |
PaymentStatusDto |
Payment is Refunded: credited (8) or reversed (17). May arrive for an order already marked paid |
$ipnPayload |
array |
Raw IPN body | |
NetopiaPaymentChargeback |
$status |
PaymentStatusDto |
Payment is in Chargeback: initiated (9), accepted (10) or representment (16). May arrive for an order already marked paid |
$ipnPayload |
array |
Raw IPN body | |
NetopiaIpnProcessingFailed |
$exception |
Throwable |
Exception thrown while handling the IPN |
$payload |
array |
Raw IPN body received from Netopia | |
$headers |
array |
Raw IPN headers | |
NetopiaReturnReceived |
$orderId |
string |
Your order identifier from the return URL — empty string if Netopia did not send orderId, in which case the package also logs a warning |
$formData |
array<string, mixed> |
POST/query data Netopia sent (everything except orderId). Pass straight to Netopay::verifyAuth() for 3DS flows |
|
$headers |
array |
Request headers |
An IPN whose status is Unknown fires no payment event; see Payment statuses.
PaymentStatusDto properties
| Property | Type | Description |
|---|---|---|
$orderId |
string |
Your order ID |
$providerPaymentId |
string |
Netopia's ntpID |
$state |
PaymentStatus |
Enum: Paid, Confirmed, Pending, Awaiting3DS, Failed, Cancelled, Expired, Refunded, Chargeback, Unknown — see Payment statuses |
$providerStatusCode |
?int |
Raw Netopia status code; null when the response has none |
$authCode |
?string |
Authorization code (available after verify-auth) |
$rrn |
?string |
Retrieval Reference Number |
$amount |
?float |
Amount charged |
$currency |
?string |
Currency code |
$paymentToken |
?string |
Saved card token, when the response carries one (verify-auth). On the IPN path read it from the event's $ipnPayload |
$errorCode |
?string |
Netopia error code |
$errorMessage |
?string |
Netopia error message |
$raw |
?string |
Full raw JSON response from Netopia |
OrderDto properties
| Property | Type | Default | Description |
|---|---|---|---|
$orderId |
string |
— | Your unique order identifier, sent as orderID |
$amount |
float |
— | Amount to charge |
$currency |
string |
— | Currency code (e.g. RON) |
$description |
string |
— | Shown on the hosted page and in Netopia's admin |
$billing |
BillingDto |
— | Billing details (also sent as shipping) |
$merchantInitiated |
bool |
false |
true for renewals the customer is not present for; adds scaExemptionInd: MIT |
$redirectUrl |
?string |
null |
Return URL for this payment only; null uses NETOPIA_REDIRECT_URL |
StartPaymentResponseDto properties
| Property | Type | Description |
|---|---|---|
$providerPaymentId |
string |
Netopia's ntpID — store it, the status check and 3DS verify need it |
$providerStatusCode |
int |
Raw Netopia status code |
$paymentUrl |
?string |
Hosted page URL to redirect the customer to |
$customerAction |
?array |
3DS action (url, authenticationToken) when required |
$token |
?string |
Card token, when the start response carries one |
$authCode |
?string |
Authorization code, when the payment is approved on start (token payments) |
$rrn |
?string |
Retrieval Reference Number, when approved on start |
$rawResponse |
string |
Full raw JSON response from Netopia |
state() |
PaymentStatus |
$providerStatusCode mapped to the enum. A hosted-page start returns status 1, which maps to Pending — wait for the IPN or the return before deciding the outcome |
Facade API
use MarianDumitru\Netopay\Facades\Netopay; // Initiate a hosted-page payment Netopay::start(OrderDto $orderData): StartPaymentResponseDto // Initiate a merchant-initiated recurring payment Netopay::startWithToken(OrderDto $orderData, string $token): StartPaymentResponseDto // Retrieve confirmed status from Netopia Netopay::retrieveStatusById(string $ntpId, string $orderId): PaymentStatusDto Netopay::retrieveStatus(PaymentStatusDto $status): PaymentStatusDto // Complete a 3DS authentication Netopay::verifyAuth(string $orderId, string $authToken, string $ntpId, array $formData): PaymentStatusDto // Parse a raw IPN body without an API call (used internally) Netopay::handleIpn(array $body, array $headers = []): PaymentStatusDto
Troubleshooting
See the raw requests and responses
NetopiaClient logs every Netopia API request and response at the debug level (Log::debug). To see them, set:
LOG_LEVEL=debug
You'll then see entries like Netopia start payment request, Netopia start payment response, Netopia IPN received, and Netopia retrieve status response in your log channel. Failures are logged at error — search for Netopia IPN processing failed.
Netopia replies with "99 POS not found" or "POS inactive"
Your NETOPIA_POS_SIGNATURE_SANDBOX / NETOPIA_POS_SIGNATURE_LIVE is wrong, empty, or still set to the README placeholder. Common causes:
- The env var is unset or empty.
- The value is still
your-sandbox-pos-signaturefrom the example block — copy-paste leftover. - The sandbox signature was used in production (or vice versa) — check
NETOPIA_SANDBOX. - Both
NETOPIA_POS_SIGNATURE_*andNETOPIA_SALES_POINT_KEYare set, and the fallback is masking the value you think is being used. RemoveNETOPIA_SALES_POINT_KEYto make the resolution explicit.
Verify the signature in your Netopia merchant dashboard and re-run the call with LOG_LEVEL=debug to inspect the exact request payload (Netopia start payment request).
Missing orderId on the return route
If your NetopiaReturnReceived listener gets called with an empty $event->orderId, the package emits:
[warning] Netopia return received with no orderId
…with the query string, input, and headers attached. This usually means Netopia is not appending orderId to your NETOPIA_REDIRECT_URL — double-check the URL you configured on the Netopia merchant dashboard.
Verify your install end-to-end
A quick sandbox smoke test:
- Enable debug logging —
LOG_LEVEL=debugin.env, thenphp artisan config:clear. - Register a one-shot listener in
routes/web.phpor a tinker session, just to see events fire:Event::listen(\MarianDumitru\Netopay\Events\NetopiaPaymentApproved::class, fn ($e) => logger()->info('approved', $e->status->toArray()));
- Initiate a payment with a sandbox test card (Netopia provides these in their dashboard) via
Netopay::start($orderData), redirect, complete the form. - Inspect the
storage/logs/laravel.logfile — you should see thestart paymentrequest/response, then the IPN payload, then your "approved" log line. - Re-fetch status manually via
php artisan tinker:\MarianDumitru\Netopay\Facades\Netopay::retrieveStatusById('<ntpID>', '<orderId>');
using the IDs from step 4 to confirm round-trip works.
If any step is missing, the log usually points at which one.
Testing
Mocking the HTTP client
Use Laravel's Http::fake() to mock Netopia responses in your application tests without hitting the real API:
use Illuminate\Support\Facades\Http; use MarianDumitru\Netopay\Enums\PaymentStatus; Http::fake([ '*/payment/card/start' => Http::response([ 'customerAction' => [], 'error' => ['code' => '101', 'message' => 'Redirect user to payment page'], 'payment' => [ 'ntpID' => '1234567', 'status' => 1, 'paymentURL' => 'https://secure-sandbox.netopia-payments.com/ui/card?p=TEST', ], ], 200), ]);
Mocking the client interface
For unit tests that should not touch HTTP at all, bind a fake implementation to NetopiaClientInterface in your test service provider:
use MarianDumitru\Netopay\Contracts\NetopiaClientInterface; $this->app->bind(NetopiaClientInterface::class, FakeNetopiaClient::class);
Asserting events
use Illuminate\Support\Facades\Event; use MarianDumitru\Netopay\Events\NetopiaPaymentApproved; Event::fake(); // ... trigger the IPN endpoint Event::assertDispatched(NetopiaPaymentApproved::class, function ($event) { return $event->status->orderId === 'your-order-id' && $event->ipnPayload['payment']['token'] === 'expected-card-token'; });
When asserting no package events were dispatched, prefer
Event::assertNotDispatched(SpecificEvent::class)overEvent::assertNothingDispatched(). The latter also catches internal Laravel framework events and will produce false failures.
Changelog
Please see CHANGELOG for recent changes.
Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Make sure tests pass before submitting:
composer test
composer analyse
composer format
Security
If you discover a security vulnerability, please report it via the GitHub Security Advisory rather than the public issue tracker.
License
The MIT License (MIT). Please see LICENSE for details.