apility/nets-easy
Laravel integration for Nets Easy payments. Create and manage payment objects, handle callbacks and webhooks, and connect checkout flows with configurable API credentials. Supports common payment operations and provides a clean, practical way to add Nets Easy to Laravel applications.
The package is a thin, framework-agnostic SDK around the Nets Easy (formerly DIBS) REST API. There is no service provider, no facade, and no config file — expect to wire it yourself in app/Providers/AppServiceProvider.php or a dedicated NetsEasyServiceProvider.
1. Install
composer require apility/nets-easy guzzlehttp/guzzle
The library leans on a PSR-18 HTTP client, so add one explicitly. Guzzle is the safe default and ships with Laravel already.
2. Publish the secret
Nets Easy identifies test vs. production via the secret key prefix (test-secret-key-… vs. live-secret-key-…). There is no separate URL for the sandbox. Store it in config/services.php and .env:
// config/services.php
'nets_easy' => [
'secret_key' => env('NETS_EASY_SECRET_KEY'),
],
3. Bind the client as a singleton
// AppServiceProvider::register()
$this->app->singleton(\NetsEasy\Api\Client::class, function ($app) {
return new \NetsEasy\Api\Client(
config('services.nets_easy.secret_key'),
new \GuzzleHttp\Client(['timeout' => 10]),
);
});
4. First real use case: a hosted checkout
The 90% case is "create payment → redirect to hostedPaymentPageUrl → handle the callback". Your controller:
public function checkout(Request $request, Client $nets)
{
$payment = $nets->payment()->create([
'order' => [
'items' => [[
'reference' => 'SKU-1',
'name' => 'T-shirt',
'quantity' => 1,
'unitPrice' => 19900, // øre, not kr
'taxRate' => 2500, // 25%
'taxAmount' => 3980,
'grossTotalAmount' => 19900,
'netTotalAmount' => 15920,
]],
'amount' => 19900,
'currency' => 'NOK',
'reference' => (string) $order->id,
],
'checkout' => [
'url' => route('checkout.return'), // user lands here after paying
'cancelUrl' => route('checkout.cancel'),
'termsUrl' => 'https://example.com/terms',
],
'notifications' => [
'webHooks' => [['eventName' => 'payment.checkout.completed',
'url' => route('nets.callback'),
'authorization' => config('services.nets_easy.webhook_secret')]],
],
]);
$order->update(['nets_payment_id' => $payment->paymentId]);
return redirect($payment->hostedPaymentPageUrl);
}
The first thing to internalise: you never render a payment form. Nets hosts it.
paymentId immediately, treat it as your primary keyNets calls back with only a paymentId. Every subsequent operation (getPayment, charge, refund, cancel) requires it. Store it on your orders table with a unique index and look payments up by it, not by your own reference.
Schema::table('orders', function (Blueprint $t) {
$t->string('nets_payment_id')->nullable()->unique()->index();
});
notifications.webHooks) is per-payment, configured at creation time, and points at your URL. Use it for "this order moved forward".Authorization header you set, and covers account-wide events (payment.charge.created, payment.refund.completed, etc.).For most Laravel apps, do state changes off the callback (simple, per-order, testable) and use the webhook only for reconciliation of charges/refunds you initiated.
public function callback(Request $request, Client $nets)
{
$payment = $nets->payment()->get($request->input('paymentId'));
if ($payment->summary->reservedAmount > 0) {
$order = Order::where('nets_payment_id', $payment->paymentId)->firstOrFail();
// Now you can charge
$nets->charge()->create($payment->paymentId, [
'amount' => $payment->summary->reservedAmount,
]);
}
return response()->noContent();
}
Always re-fetch via get() rather than trusting the callback body — callbacks are unauthenticated POSTs.
Nets Easy is a two-step flow:
POST /payments → user pays → funds reserved.PUT /payments/{id}/charges → funds captured.If your business is one-shot (most SaaS), just charge the full reserved amount in the callback. If you do delayed capture or partial fulfilment, hold the reservation and charge later — reservations expire (typically 7 days), so schedule a CancelUncapturedOrders job rather than letting them lapse with money in limbo.
$charge = $nets->charge()->create($paymentId, ['amount' => 19900]);
// $charge->chargeId — persist this for refunds
Refunds need the chargeId, not the paymentId. Store it the moment you capture.
$nets->charge()->create() is an HTTP call. Don't do it inside the user's redirect request if you can avoid it — dispatch ChargeOrder on a queue and let the return page poll the local order status.
class ChargeOrder implements ShouldQueue
{
public function handle(Client $nets)
{
$charge = $nets->charge()->create($this->order->nets_payment_id, [
'amount' => $this->order->total_minor,
]);
$this->order->update(['nets_charge_id' => $charge->chargeId, 'status' => 'paid']);
}
}
Use a test-secret-key-… from the Nets portal. Cards like 4925 0000 0000 0001 with any future expiry are accepted. There is no mocking layer in the SDK — for unit tests, bind a fake Client via the container and assert the calls you expect.
The SDK passes idempotency through — set an X-Idempotency-Key (via the request options or duplicate-guard on your side) for create and charge calls. Retries with the same key won't double-charge. A unique index on nets_charge_id is your real safety net.
Every amount (amount, unitPrice, grossTotalAmount, reservedAmount, chargedAmount) is in øre/cents. 19.99 NOK is 1999. There is no helper. Wrap conversions in a Money cast or you will ship an order of magnitude bug.
NetsEasy\Api\Client is the only thing you bind. If you composer update and get a "Class not found", you've almost certainly bumped a version where the namespace or constructor signature changed (last release was Nov 2022 — expect little churn but no semver guarantees). Pin the version.
ApiException (extending whatever base the version ships) carries the HTTP status and the raw response. There is no granular per-error class. You have to inspect the body:
try {
$nets->charge()->create($id, ['amount' => $amount]);
} catch (\NetsEasy\Api\ApiException $e) {
Log::warning('Nets charge failed', ['status' => $e->getCode(), 'body' => $e->getResponse()?->getBody()]);
// 402 = card declined, 409 = already charged, 400 = bad payload
}
Wrap this — the SDK throws for 4xx and 5xx, both.
The webhook Authorization header is the only signature Nets provides (no HMAC on the body). Validate it with hash_equals(config('…webhook_secret'), $request->header('Authorization')) before doing anything. If you forget, anyone can POST payment.charge.created to your endpoint.
Nets retries callbacks for hours if you don't 2xx. Make the handler idempotent (guard on order status), return 204, and always re-fetch the payment. Attackers can hit the URL with arbitrary paymentIds; get() will fail loudly for invalid ones.
Reserved payments auto-expire after ~7 days. If your fulfilment logic can be that slow, either capture up front or run a scheduled CancelExpiredReservations job that calls $nets->charge()->cancel($paymentId) and marks the order.
$nets->refund()->create($paymentId, $chargeId, ['amount' => …]) — pass the chargeId, and the amount must be ≤ what's left on that charge. Nets doesn't stop you calling refund twice; track refunded totals locally.
laravel/pint, no Laravel code pathsDo not expect Http::fake() to intercept calls — the SDK uses PSR-18 directly. Use Http::fake() only if you swap in guzzlehttp/guzzle bound to Laravel's client, otherwise bind a fake Client implementation in tests.
Pass an explicit timeout when constructing Guzzle (10s is reasonable). The SDK does not retry — Laravel's retry(3, 200) around $nets->payment()->create() is fine because payment creation is idempotent with a supplied key, but never blind-retry charges without idempotency.
dd($payment) a raw get() result — the response objects are loosely typed stdClass in many call sites, so IDE auto-complete lies. Check $response->paymentId, $response->summary->reservedAmount, $response->charges[0]->chargeId.hostedPaymentPageUrl is null, you're almost certainly missing termsUrl or an item field (taxAmount, grossTotalAmount); Nets silently rejects partial item definitions on create in some accounts.There are none in the SDK sense — no events, no middleware hooks. Your extension points are all Laravel-side: a NetsEasy wrapper class around Client, an Order observer, and a queue job per operation. Build that wrapper once and the rest of the app never touches NetsEasy\Api\* again.
How can I help you explore Laravel packages today?