Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nets Easy Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

Persist the paymentId immediately, treat it as your primary key

Nets 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();
});

Split "callback" from "webhook" — they are different things

  • Callback (notifications.webHooks) is per-payment, configured at creation time, and points at your URL. Use it for "this order moved forward".
  • Webhook is a global URL configured in the Nets merchant portal, signed via the 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.

Reserve-then-charge is the standard workflow

Nets Easy is a two-step flow:

  1. POST /payments → user pays → funds reserved.
  2. 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.

Use the Laravel queue for anything after the redirect

$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']);
    }
}

Test with a real test key

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.

Idempotency

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.

Gotchas and Tips

Amounts are in minor units — always

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.

No service provider, no facade, no auto-discovery

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.

The exception hierarchy is thin

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.

Webhook verification is your job

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.

The callback is unauthenticated — treat it as a hint

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.

Reservation lifetime

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.

Partial refunds sum, full refunds close

$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.

No laravel/pint, no Laravel code paths

Do 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.

Timeouts and retries

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.

Debugging tips

  • 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.
  • The Nets merchant portal shows the same events the API returns — when in doubt, cross-reference the portal's payment ID.
  • If 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.

Extension points

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource