PHPackages                             nordkit/svea - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. nordkit/svea

ActiveLibrary[Payment Processing](/categories/payments)

nordkit/svea
============

Modern PHP SDK for Svea Checkout — Checkout, Admin, Subscriptions and Webhooks

v1.2.0(1w ago)0154↓100%2MITPHPPHP ^8.2CI passing

Since May 7Pushed 1w agoCompare

[ Source](https://github.com/nordkit/svea)[ Packagist](https://packagist.org/packages/nordkit/svea)[ GitHub Sponsors](https://github.com/nordkit)[ RSS](/packages/nordkit-svea/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (7)Versions (5)Used By (0)

`nordkit/svea` — Modern PHP SDK for Svea Checkout
=================================================

[](#nordkitsvea--modern-php-sdk-for-svea-checkout)

A ground-up PHP SDK for Svea's APIs: **Checkout**, **Payment Admin**, **Webhook Subscriptions**, and **inbound Webhook verification** — with a fluent, expressive API, a full Laravel integration, and a first-class testing layer.

[![Packagist Version](https://camo.githubusercontent.com/d24692927bf8681c1e8ab7da4ee716fa697a0507b4277942f550c4fb8fd64727/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6e6f72646b69742f737665612e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/nordkit/svea)[![Total Downloads](https://camo.githubusercontent.com/9954c9e3f20a9ee1b4a9b0759f19b60fa3b7c1bbe4c057d8258dbb4319382b17/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6e6f72646b69742f737665612e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/nordkit/svea)[![Tests](https://camo.githubusercontent.com/b2bec2084bc7d5ec15d3d965f5dd4bc00f7b419301324330be0f1c900034e68a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6e6f72646b69742f737665612f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/nordkit/svea/actions/workflows/tests.yml)[![PHPStan](https://camo.githubusercontent.com/79ccd43815a7df7150f9d33e770b3c3dce3cb8ab6dfe9bce0fbd9928de10dbaf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230362d627269676874677265656e2e7376673f7374796c653d666c61742d737175617265)](phpstan.neon)[![PHP Version](https://camo.githubusercontent.com/f5e6a4a6be05968f70824600ca2b05103158a4c80fb7c3683723ee4fe1da366d/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6e6f72646b69742f737665612e7376673f7374796c653d666c61742d737175617265)](composer.json)[![Laravel](https://camo.githubusercontent.com/f5d62e7d3b2a28a1de548d91e3148cd8ca5d39940c998cd739a9a278675a0746/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d3131253230253743253230313225323025374325323031332d7265642e7376673f7374796c653d666c61742d737175617265)](composer.json)[![License](https://camo.githubusercontent.com/3fbc769216b078fcbde1424e8a49115322e175782fa5b73cde32560b4ffc9bbc/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6e6f72646b69742f737665612e7376673f7374796c653d666c61742d737175617265)](LICENSE.md)

> 📖 **Official Svea API documentation:** [paymentsdocs.svea.com](https://paymentsdocs.svea.com/)

---

At a glance
-----------

[](#at-a-glance)

FeatureStatus**Checkout API** — create, get, update, cancel orders✅**Payment Admin API** — deliver, cancel, credit, modify rows✅**Webhook Subscriptions** — full CRUD + verification✅**Inbound Webhook verification** — HMAC-SHA256, timing-safe✅**Laravel integration** — service provider, facade, Artisan commands✅**Test doubles** — `Svea::fake()` with assertion helpers (Http::fake-style)✅**Idempotency keys** — safe queue retries on Admin operations✅**Retries** — opt-in exponential backoff on 429 / 5xx✅**Async task polling** — typed `TaskResponse` for HTTP 202 operations✅**Conditionable** — `when()` / `unless()` for fluent branching✅**Typed exceptions** — `SveaApiException` hierarchy with status code &amp; body✅**Strict types &amp; `final readonly` value objects** — PHPStan level 6, zero errors✅**PHP support** — 8.2, 8.3, 8.4, 8.5✅**Framework-agnostic core** — Laravel optional, runs anywhere✅---

Table of Contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [Configuration](#configuration)
- [Laravel Integration](#laravel-integration)
    - [Artisan Commands](#artisan-commands)
- [API Reference](#api-reference)
    - [Checkout](#checkout)
    - [Admin](#admin)
    - [Subscriptions](#subscriptions)
    - [Webhooks](#webhooks)
- [Testing](#testing)
- [Advanced Usage](#advanced-usage)
- [Error Handling](#error-handling)
- [Response Objects](#response-objects)
- [Package Structure](#package-structure)
- [Contributing](#contributing)

---

Requirements
------------

[](#requirements)

DependencyVersionPHP^8.2`guzzlehttp/guzzle`^7.8`illuminate/support` *(optional)*^11.0 | ^12.0 | ^13.0 — required for the Laravel facade and service provider---

Installation
------------

[](#installation)

```
composer require nordkit/svea
```

**Laravel** — the service provider and facade are auto-discovered. Publish the config file:

```
php artisan vendor:publish --tag=svea-config
```

**Standalone (no Laravel)** — instantiate `SveaClient` directly with a config array (see [Configuration](#configuration)).

---

Quick Start
-----------

[](#quick-start)

> 💡 **Prefer learning by example?** Check out [`nordkit/svea-example-laravel`](https://github.com/nordkit/svea-example-laravel) — a minimal Laravel 13 app demonstrating the full **cart → checkout → webhook** flow, with a feature test suite using `Svea::fake()`.

### Create a checkout order

[](#create-a-checkout-order)

All numeric values follow Svea's **minor-unit convention**:

- `quantity` — `100` = 1 unit, `300` = 3 units
- `unitPrice` — `29900` = 299.00 SEK (minor currency, e.g. öre)
- `vatPercent` — `2500` = 25%, `1900` = 19%
- `discountPercent` — `1000` = 10%

Common Nordic checkout defaults:

MarketCurrencyLocaleCountry codeSweden`SEK``sv-SE``SE`Norway`NOK``nn-NO``NO`Denmark`DKK``da-DK``DK`Finland`EUR``fi-FI``FI````
use Svea\Checkout\Cart;
use Svea\Checkout\CheckoutOrder;
use Svea\Checkout\MerchantSettings;
use Svea\Checkout\OrderRow;

$order = Svea::checkout()->create(new CheckoutOrder(
    currency: 'SEK',
    countryCode: 'SE',
    locale: 'sv-SE',
    clientOrderNumber: 'ORD-001',
    merchantSettings: new MerchantSettings(
        pushUri: route('webhooks.svea'),
        termsUri: route('terms'),
        confirmationUri: route('checkout.confirmation'),
        checkoutUri: route('checkout'),
    ),
    cart: new Cart([
        new OrderRow(quantity: 100, unitPrice: 29900, vatPercent: 2500, sku: 'TSHIRT-BLK-M', name: 'T-Shirt Black M'),
        new OrderRow(quantity: 200, unitPrice: 89900, vatPercent: 2500, sku: 'SNEAKER-WHT-42', name: 'Sneakers White 42'),
    ]),
));

$order->id();        // '12345678' — store this as your Svea order ID
$order->snippet();   // '...' — embed in your checkout page
$order->status();    // 'Created' | 'Final' | 'Cancelled'
```

### Fluent callback style

[](#fluent-callback-style)

Great for composable builds and `when()` branches:

```
$order = Svea::checkout()->create(function (CheckoutOrder $order) use ($cart) {
    $order
        ->currency('SEK')
        ->locale('sv-SE')
        ->countryCode('SE')
        ->clientOrderNumber($cart->order_number)
        ->merchantSettings(fn (MerchantSettings $s) => $s
            ->pushUri(route('webhooks.svea'))
            ->termsUri(route('terms'))
            ->confirmationUri(route('checkout.confirmation'))
            ->checkoutUri(route('checkout')));

    foreach ($cart->items as $item) {
        $order->addRow(function (OrderRow $row) use ($item) {
            $row->sku($item->sku)
                ->name($item->name)
                ->quantity($item->qty)
                ->unitPrice($item->unit_price)   // incl. VAT, minor currency (öre)
                ->vatPercent($item->vat_percent) // minor units: 2500 = 25%
                ->unit('st');
        });
    }
});
```

### Conditional chaining with `when()`

[](#conditional-chaining-with-when)

```
Svea::admin()->order('12345678')
    ->withIdempotencyKey($payment->id)
    ->when($isPartialDelivery,
        fn ($req) => $req->deliver(rows: $rowIds),
        fn ($req) => $req->deliver(),  // else branch
    );
```

### Standalone (no Laravel)

[](#standalone-no-laravel)

```
use Svea\SveaClient;

$svea = new SveaClient([
    'merchant_id'    => 'abc',
    'shared_secret'  => 'xyz',
    'environment'    => 'test',
    'webhook_secret' => 'whsec_...',
]);

$svea->checkout->create(...);
$svea->admin->order('12345678')->deliver();
```

---

Authentication
--------------

[](#authentication)

### Outbound API requests

[](#outbound-api-requests)

All three outbound APIs (Checkout, Admin, Subscriptions) use Svea's **HMAC-SHA512** digest:

```
Authorization: SveaCheckoutGateway {merchantId} {base64(sha512(body + sharedSecret))}

```

`SveaConnector` computes and attaches this header automatically on every request using `merchant_id` and `shared_secret` from config.

### Inbound webhook verification

[](#inbound-webhook-verification)

`webhook_secret` is a **separate** secret used only to verify the `Svea-Signature` header on inbound webhook pushes — it is **not** the same as `shared_secret`.

```
Svea-Signature: HMAC-SHA256(raw body, webhook_secret)

```

---

Configuration
-------------

[](#configuration)

### Environment variables

[](#environment-variables)

Add these to your `.env` file:

VariableRequiredDescription`SVEA_MERCHANT_ID`✅Your Svea merchant ID`SVEA_SHARED_SECRET`✅Outbound API HMAC secret`SVEA_ENVIRONMENT`✅`test` or `production``SVEA_WEBHOOK_SECRET`✅Inbound webhook signature secret`SVEA_SUBSCRIPTION_CALLBACK_URL`—Default callback URL for subscriptions`SVEA_MAX_RETRIES`—Retry attempts on 429/500/503 (default: `0`)`SVEA_TIMEOUT`—HTTP timeout in seconds (default: `10`)`SVEA_CHECKOUT_URL`—Override Checkout API base URL`SVEA_ADMIN_URL`—Override Admin API base URL`SVEA_SUBSCRIPTIONS_URL`—Override Subscriptions API base URL### `config/svea.php`

[](#configsveaphp)

```
return [
    'merchant_id'    => env('SVEA_MERCHANT_ID'),
    'shared_secret'  => env('SVEA_SHARED_SECRET'),
    'environment'    => env('SVEA_ENVIRONMENT', 'test'), // 'test' | 'production'
    'webhook_secret' => env('SVEA_WEBHOOK_SECRET'),
    'subscription_callback_url' => env('SVEA_SUBSCRIPTION_CALLBACK_URL'),
    'max_retries'    => env('SVEA_MAX_RETRIES', 0),
    'timeout'        => env('SVEA_TIMEOUT', 10),

    // Override base URLs per API surface — useful for pointing at a local mock server.
    // When null the built-in environment defaults are used.
    'base_urls' => [
        'checkout'      => env('SVEA_CHECKOUT_URL'),      // default: https://checkoutapistage.svea.com (test)
        'admin'         => env('SVEA_ADMIN_URL'),         // default: https://paymentadminapistage.svea.com (test)
        'subscriptions' => env('SVEA_SUBSCRIPTIONS_URL'), // default: https://paymentadminapistage.svea.com (test)
    ],
];
```

---

Laravel Integration
-------------------

[](#laravel-integration)

### Auto-discovery

[](#auto-discovery)

`SveaServiceProvider` is auto-discovered via the `extra.laravel` key in `composer.json`. To register manually:

```
// bootstrap/providers.php
Svea\Laravel\SveaServiceProvider::class,
```

### Facade

[](#facade)

```
use Svea\Laravel\Svea;

Svea::checkout()->create(...);
Svea::admin()->order('12345678')->deliver();
Svea::subscriptions()->list();
```

### Laravel webhook event

[](#laravel-webhook-event)

Dispatch `SveaWebhookReceived` from your webhook controller to decouple event handling:

```
use Svea\Laravel\Events\SveaWebhookReceived;
use Svea\Laravel\WebhookService;

class SveaWebhookController
{
    public function __invoke(Request $request, WebhookService $webhookService): Response
    {
        $event = $webhookService->fromRequest($request); // throws SignatureVerificationException on mismatch
        SveaWebhookReceived::dispatch($event);

        return response()->noContent();
    }
}
```

### HTTP tracing with Wiretap (optional)

[](#http-tracing-with-wiretap-optional)

[nordkit/wiretap](https://github.com/nordkit/wiretap) is a framework-agnostic, configurable HTTP tracing package that captures inbound and outbound HTTP requests and responses — recording headers, payloads, status codes, and timing — with built-in filtering and redaction controls. It works great with Laravel. Integrating it with `SveaClient` gives you full visibility into every API call made to Svea, and with inbound tracing enabled (`WIRETAP_INBOUND=true`) it also keeps a full log of all incoming webhook pushes and payment callbacks — useful for debugging and auditing the complete order lifecycle.

Override the `SveaClient` singleton to inject a `HandlerStack` with Wiretap or any Guzzle middleware:

```
use GuzzleHttp\HandlerStack;
use Nordkit\Wiretap\Guzzle\WiretapMiddleware;
use Nordkit\Wiretap\Wiretap;
use Svea\SveaClient;

// In your AppServiceProvider::register():
$this->app->singleton(SveaClient::class, function ($app): SveaClient {
    $stack = HandlerStack::create();
    $stack->push(WiretapMiddleware::make($app->make(Wiretap::class)));

    return new SveaClient(
        config: (array) $app['config']['svea'],
        handlerStack: $stack,
    );
});
```

See [Advanced Usage — Custom Middleware](#custom-middleware) for other middleware examples.

### Artisan Commands

[](#artisan-commands)

Six commands cover the full subscription lifecycle. All API calls go out from the machine running the command — run them **locally** if the server cannot reach Svea (e.g. Laravel Cloud with no outbound firewall exception).

#### `svea:subscription:add`

[](#sveasubscriptionadd)

```
# Register for all event types using the default callback URL from config
php artisan svea:subscription:add

# Override the callback URL
php artisan svea:subscription:add --url=https://staging.myapp.com/v2/webhooks/svea/subscription

# Subscribe to specific events only
php artisan svea:subscription:add --events=CheckoutOrder.Created,CheckoutOrder.Delivered

# Skip the automatic verification Ping
php artisan svea:subscription:add --no-verify
```

Default callback URL: `app.url` + `/v2/webhooks/svea/subscription`. Default events: all except `Ping`.

#### `svea:subscription:list`

[](#sveasubscriptionlist)

```
php artisan svea:subscription:list
```

Outputs a table of ID, Callback URL, Verified status, and subscribed event types.

#### `svea:subscription:get`

[](#sveasubscriptionget)

```
php artisan svea:subscription:get {id}
```

#### `svea:subscription:verify`

[](#sveasubscriptionverify)

```
php artisan svea:subscription:verify {id}
```

Required after `--no-verify` or after changing a URL via `svea:subscription:update`.

#### `svea:subscription:update`

[](#sveasubscriptionupdate)

```
# Change the URL (requires re-verification)
php artisan svea:subscription:update {id} --url=https://new.myapp.com/v2/webhooks/svea/subscription

# Change events
php artisan svea:subscription:update {id} --events=CheckoutOrder.Created,CheckoutOrder.Closed

# Change URL and re-verify in one step
php artisan svea:subscription:update {id} --url=https://new.myapp.com/... --verify
```

#### `svea:subscription:remove`

[](#sveasubscriptionremove)

```
php artisan svea:subscription:remove {id}

# Skip the confirmation prompt
php artisan svea:subscription:remove {id} --force
```

---

API Reference
-------------

[](#api-reference)

### Checkout

[](#checkout)

#### Create

[](#create)

All numeric values follow Svea's **minor-unit convention**: `quantity` (`100` = 1 unit), `unitPrice` (minor currency, e.g. `29900` = 299.00 SEK), `vatPercent` (`2500` = 25%), `discountPercent` (`1000` = 10%).

**Named constructor style** — best when all data is available upfront:

```
use Svea\Checkout\Cart;
use Svea\Checkout\CheckoutOrder;
use Svea\Checkout\MerchantSettings;
use Svea\Checkout\OrderRow;

$order = Svea::checkout()->create(new CheckoutOrder(
    currency: 'SEK',
    countryCode: 'SE',
    locale: 'sv-SE',
    clientOrderNumber: 'ORD-001',
    merchantSettings: new MerchantSettings(
        pushUri: route('webhooks.svea'),
        termsUri: route('terms'),
        confirmationUri: route('checkout.confirmation'),
        checkoutUri: route('checkout'),
    ),
    cart: new Cart([
        new OrderRow(quantity: 100, unitPrice: 29900, vatPercent: 2500, sku: 'TSHIRT-BLK-M', name: 'T-Shirt Black M'),
    ]),
));

$order->id();                          // '12345678'
$order->snippet();                     // '...' — embed in checkout page
$order->status();                      // 'Created' | 'Final' | 'Cancelled'
$order->successful();                  // bool
$order->getLastResponse()->statusCode; // 201
```

**Fluent callback style** — better for loops, conditional rows, and composable builds:

```
$order = Svea::checkout()->create(function (CheckoutOrder $order) use ($cart) {
    $order
        ->currency('SEK')
        ->countryCode('SE')
        ->locale('sv-SE')
        ->clientOrderNumber($cart->reference)
        ->merchantSettings(fn (MerchantSettings $s) => $s
            ->pushUri(route('webhooks.svea'))
            ->termsUri(route('terms'))
            ->confirmationUri(route('checkout.confirmation'))
            ->checkoutUri(route('checkout')));

    foreach ($cart->items as $item) {
        $order->addRow(fn (OrderRow $row) => $row
            ->sku($item->sku)
            ->name($item->name)
            ->quantity($item->qty * 100)   // minor units: 100 = 1 unit
            ->unitPrice($item->unit_price) // incl. VAT, minor currency (öre)
            ->vatPercent($item->vat_percent) // minor units: 2500 = 25%
            ->unit('st'));
    }

    $order->when($cart->has_discount, fn ($o) => $o->addRow(
        fn (OrderRow $r) => $r->sku('DISC')->name('Discount')->unitPrice(-500)->quantity(100)->vatPercent(2500)
    ));
});
```

**Supported locales:** `sv-SE`, `da-DK`, `de-DE`, `en-US`, `fi-FI`, `nn-NO`.

**Optional fields** — chain on either style:

```
$order->merchantData('ref:order-42')           // opaque metadata (max 6000 chars)
      ->partnerKey('uuid-from-svea')           // Svea partner key
      ->recurring()                            // create a recurring token on finalisation
      ->requireElectronicIdAuthentication()    // require BankID or equivalent
      ->metadata(['orderId' => 'ORD-001']);    // key-value pairs visible in Svea portal (45-day TTL)
```

#### Get

[](#get)

```
$order = Svea::checkout()->get('12345678');

$order->id();      // '12345678'
$order->status();  // 'Created' | 'Cancelled' | 'Final'
$order->snippet(); // '...'
```

#### Update

[](#update)

`update()` accepts the same named-constructor or fluent callback as `create()` — only set the fields you want to change:

```
$order = Svea::checkout()->update('12345678', function (CheckoutOrder $order) use ($extraItem) {
    $order->addRow(fn (OrderRow $row) => $row
        ->sku($extraItem->sku)
        ->name($extraItem->name)
        ->quantity(100)
        ->unitPrice(5000)
        ->vatPercent(2500));
});

$order->id();      // '12345678'
$order->status();  // 'Created' | 'Cancelled' | 'Final'
```

#### Cancel

[](#cancel)

```
Svea::checkout()->cancel('12345678'); // void
```

---

### Admin

[](#admin)

#### Deliver (capture)

[](#deliver-capture)

`deliver()` returns a `DeliverResponse` with the new delivery ID and an async task reference URL.

```
// Deliver all rows
$deliver = Svea::admin()->order('12345678')->deliver();

// Deliver specific rows with an idempotency key (safe for queue retries)
$deliver = Svea::admin()
    ->order('12345678')
    ->withIdempotencyKey('deliver-' . $paymentEventId)
    ->deliver(rows: [101, 102]);

$deliver->deliveryId();    // int — store to reference this delivery in credit calls
$deliver->taskReference(); // 'https://paymentadminapi.svea.com/api/v1/tasks/456' — poll for completion
$deliver->getLastResponse()->statusCode; // 202
```

#### Cancel

[](#cancel-1)

```
Svea::admin()->order('12345678')->cancel();
Svea::admin()->order('12345678')->cancelAmount(50000);
Svea::admin()->order('12345678')->cancelRow(rowId: 101);
```

#### Credit (refund)

[](#credit-refund)

```
// Credit specific rows on a delivery
$task = Svea::admin()
    ->order('12345678')
    ->delivery(456)
    ->credit()
    ->rows([101, 102])
    ->send();

// Credit a fixed amount
$task = Svea::admin()->order('12345678')->delivery(456)->creditAmount(9900);

// Credit a new row — fluent callback style
$task = Svea::admin()
    ->order('12345678')
    ->delivery(456)
    ->credit()
    ->newRow(fn (AdminOrderRow $row) => $row->name('Return fee')->unitPrice(5000)->quantity(100)->vatPercent(2500))
    ->send();
```

#### Get order details

[](#get-order-details)

```
$adminOrder = Svea::admin()->order('12345678')->get();

$adminOrder->status();             // SveaOrderStatus enum
$adminOrder->actions();            // string[] — e.g. ['CanDeliverOrder', 'CanCancelOrder']
$adminOrder->canDeliver();         // bool
$adminOrder->canCredit();          // bool
$adminOrder->canCancel();          // bool
$adminOrder->deliveries();         // array — all deliveries on the order
$adminOrder->delivery(456);        // array|null — specific delivery by ID
$adminOrder->deliveryRowIds(456);  // int[] — row IDs belonging to delivery 456 (useful before crediting)
$adminOrder->hasAction('CanDeliverOrder'); // bool — check any action string
$adminOrder->hasStatus('Open');    // bool — check status string directly
```

#### Modify order rows

[](#modify-order-rows)

```
// Add a new row — returns the new row ID and a task reference
$result = Svea::admin()->order('12345678')->addOrderRow(function (AdminOrderRow $row) {
    $row->name('Extra item')
        ->sku('EXTRA-1')
        ->unitPrice(5000)
        ->quantity(100)
        ->vatPercent(2500)
        ->unit('st');
});

$result['order_row_id'];   // int
$result['task_reference']; // string — async task URL

// Update a single existing row by its row ID
Svea::admin()->order('12345678')->updateOrderRow(rowId: 101, callback: function (AdminOrderRow $row) {
    $row->unitPrice(4500)->name('Updated name');
});

// Replace all rows at once — each callback builds one replacement row
Svea::admin()->order('12345678')->replaceOrderRows(
    fn (AdminOrderRow $row) => $row->name('Widget')->sku('WGT-1')->unitPrice(9900)->quantity(100)->vatPercent(2500),
    fn (AdminOrderRow $row) => $row->name('Shipping')->sku('SHIP')->unitPrice(4900)->quantity(100)->vatPercent(2500),
);
```

#### Poll a task

[](#poll-a-task)

Admin operations that mutate order state (`deliver()`, `creditAmount()`, `credit()->send()`) are **asynchronous** — Svea accepts the request immediately (HTTP 202) and processes it in the background. The response carries a task reference URL; poll it until the task completes or fails.

```
// deliver() returns a DeliverResponse with the task URL
$deliver = Svea::admin()->order('12345678')->deliver();
$taskUrl = $deliver->taskReference(); // 'https://paymentadminapi.svea.com/api/v1/tasks/456'

// Poll until done (simple loop — use a queued job in production)
do {
    sleep(1);
    $task = Svea::admin()->task($taskUrl);
} while ($task->pending());

if ($task->failed()) {
    // handle failure
}

$task->completed(); // bool
$task->failed();    // bool
$task->pending();   // bool — true while still processing
$task->resource;    // string|null — URL to the resulting resource (e.g. the delivery) once complete
```

> **In production** run the poll loop inside a queued job with retries rather than blocking an HTTP request. Store `$deliver->taskReference()` and `$deliver->deliveryId()` immediately after calling `deliver()`.

#### Conditional chaining with `when()` / `unless()`

[](#conditional-chaining-with-when--unless)

```
Svea::admin()
    ->order($externalOrderId)
    ->when(! empty($partialRows), fn ($o) => $o->deliver(rows: $partialRows))
    ->unless(! empty($partialRows), fn ($o) => $o->deliver());
```

---

### Subscriptions

[](#subscriptions)

**Webhook subscriptions** are how Svea notifies your application when order lifecycle events occur — a payment is captured, a credit succeeds, an order is closed. You register a HTTPS endpoint once per merchant; Svea pushes a signed JSON payload to that URL whenever a subscribed event fires.

> **Subscriptions vs task polling** — These are two separate mechanisms:
>
> SubscriptionsTask polling**What**Svea pushes order lifecycle events to your URLYou poll an async Admin API operation until it completes**When**Order created, delivered, credited, closed, etc.After `deliver()`, `creditAmount()`, etc. return a `TaskResponse`**Direction**Svea → your server (push)Your server → Svea (pull)**Setup**Register once, stays activePer-operation, URL returned in the responseSee [Poll a task](#poll-a-task) under Admin for the task-polling API.

#### Available event types

[](#available-event-types)

`EventType` caseSvea event stringWhen it fires`CheckoutOrderCreated``CheckoutOrder.Created`Order created; `IsPending` = true if awaiting Svea approval`CheckoutOrderUpdated``CheckoutOrder.Updated`Order edited or explicit sync — use GET to refresh your state`CheckoutOrderDelivered``CheckoutOrder.Delivered`Order partially or fully captured`CheckoutOrderCreditSucceeded``CheckoutOrder.CreditSucceeded`Credit (refund) processed successfully`CheckoutOrderCreditFailed``CheckoutOrder.CreditFailed`An accepted credit operation subsequently failed`CheckoutOrderClosed``CheckoutOrder.Closed`Order cancelled or expired (`CloseReason`: `Cancelled` / `Expired`)`CheckoutOrderPendingStatusReleased``CheckoutOrder.PendingStatusReleased`Pending order approved by Svea`StandaloneOrderPendingStatusReleased``StandaloneOrder.PendingStatusReleased`Standalone pending order approved`StandaloneOrderClosed``StandaloneOrder.Closed`Standalone order closed`Ping``Ping`Sent by `verify()` to confirm your endpoint is reachable — handle it, don't subscribe to it> **⚠️ Checkout order finalized is not a subscription event.** When a customer completes payment, Svea POSTs a `{"type": "Finalized"}` payload to the **merchant push** (`pushUri`) configured on `MerchantSettings` per order — it is **not** delivered via the subscription webhook system. Your `pushUri` endpoint receives the push with the order ID in the URL path; you must then call `Svea::admin()->order($orderId)->get()` to read the Payment Admin status and determine next steps (e.g. `Open` → capture, `Cancelled` → cancel). Note that the checkout order status `Final` (status code `100`) only means the checkout session is closed — it does **not** indicate the order is ready for delivery.

#### Registration workflow

[](#registration-workflow)

A new subscription must be **verified** before Svea will deliver events to it. `add()` + `verify()` in one go is the recommended path:

> **Tip:** In a Laravel application you can manage subscriptions via Artisan instead of writing code — see [Artisan Commands](#artisan-commands) under Laravel Integration for `svea:subscription:add`, `svea:subscription:verify`, and related commands.

```
use Svea\Subscriptions\EventType;

$subscription = Svea::subscriptions()->add(
    callbackUrl: 'https://myapp.com/webhooks/svea',
    eventTypes: [
        EventType::CheckoutOrderCreated,
        EventType::CheckoutOrderDelivered,
        EventType::CheckoutOrderCreditSucceeded,
        EventType::CheckoutOrderCreditFailed,
        EventType::CheckoutOrderClosed,
    ],
);

// Svea sends a Ping to your endpoint — it must respond 2xx within the timeout
Svea::subscriptions()->verify($subscription->id());
```

Or via the fluent builder (calls `verify()` automatically after `register()`):

```
$subscription = Svea::subscriptions()
    ->on(EventType::CheckoutOrderCreated, EventType::CheckoutOrderDelivered)
    ->notifyAt('https://myapp.com/webhooks/svea')
    ->register(); // registers and verifies
```

> **Re-verification:** Changing a subscription's URL via `update()` invalidates verification — call `verify()` again before events will resume.

#### Inspect a subscription

[](#inspect-a-subscription)

```
$subscription->id();           // 'fbb6c74a-...'
$subscription->callbackUrl();  // 'https://myapp.com/webhooks/svea'
$subscription->events();       // EventType[]
$subscription->isVerified();   // bool — false means events are not being delivered
$subscription->createdAt();    // \DateTimeImmutable|null
```

#### Get / List / Update / Remove

[](#get--list--update--remove)

```
$subscription = Svea::subscriptions()->get('sub-id');

$subscriptions = Svea::subscriptions()->list(); // array

// Update URL or events — URL change requires re-verification
$updated = Svea::subscriptions()->update(
    'sub-id',
    'https://myapp.com/webhooks/svea-new',
    [EventType::CheckoutOrderCreated]
);
Svea::subscriptions()->verify('sub-id'); // required after URL change

Svea::subscriptions()->remove('sub-id');
```

---

### Webhooks

[](#webhooks)

#### Verify and parse inbound events

[](#verify-and-parse-inbound-events)

```
use Svea\Webhooks\Webhook;
use Svea\Exceptions\SignatureVerificationException;

// Framework-agnostic (pure static — works anywhere):
try {
    $event = Webhook::constructEvent(
        payload:   file_get_contents('php://input'),
        signature: $_SERVER['HTTP_SVEA_SIGNATURE'] ?? '',
        secret:    getenv('SVEA_WEBHOOK_SECRET'),
    );
} catch (SignatureVerificationException $e) {
    http_response_code(400);
    exit;
}

// Laravel shorthand via facade:
$event = Svea::webhook()->fromRequest($request);
```

#### Working with the event

[](#working-with-the-event)

```
$event->type;         // EventType enum
$event->orderId;      // string
$event->deliveryId;   // string|null
$event->occurredAt;   // \DateTimeImmutable

match ($event->type()) {
    EventType::CheckoutOrderDelivered       => $this->handleDelivered($event),
    EventType::CheckoutOrderCreditSucceeded => $this->handleCredited($event),
    EventType::CheckoutOrderClosed         => $this->handleClosed($event),
    default                                => null,
};
```

---

Testing
-------

[](#testing)

### `Svea::fake()`

[](#sveafake)

Swap the real client for a fake in Pest/PHPUnit tests. Mirrors Laravel's `Http::fake()` pattern.

> **Tip:** All fluent builders (`CheckoutOrder`, `OrderRow`, `MerchantSettings`, `AdminOrderRow`) expose a `make()` named constructor that returns a blank instance — identical to `new ClassName()`. Inside `Svea::fake()` callbacks the builders are passed pre-constructed, so you never need to call `make()` directly in test code.

```
use Svea\Admin\AdminOrderResponse;
use Svea\Admin\TaskResponse;
use Svea\Checkout\CheckoutResponse;

Svea::fake([
    'checkout.create' => CheckoutResponse::make(['OrderId' => '99999999', 'Gui' => ['Snippet' => '...']]),
    'admin.get'       => AdminOrderResponse::make(['OrderStatus' => 'Open', 'Actions' => ['CanDeliverOrder']]),
    'admin.deliver'   => TaskResponse::pending('https://paymentadminapi.svea.com/api/v1/tasks/123'),
    'admin.task'      => TaskResponse::completed(),
]);

// Run code under test
$result = (new CaptureOrder($paymentManager))->execute($payment);

// Assert what was called
Svea::assertDelivered('99999999');
Svea::assertDelivered('99999999', rows: [101, 102]);
Svea::assertCredited('99999999');
Svea::assertCancelledOrder('99999999');
Svea::assertCheckoutCreated();
Svea::assertTaskPolled('https://paymentadminapi.svea.com/api/v1/tasks/123');
Svea::assertSubscriptionRegistered('https://myapp.com/webhooks/svea');
Svea::assertSubscriptionAdded('https://myapp.com/webhooks/svea');
Svea::assertSubscriptionFetched('sub-guid');
Svea::assertSubscriptionsListed();
Svea::assertSubscriptionUpdated('sub-guid');
Svea::assertSubscriptionRemoved('sub-guid');
Svea::assertSubscriptionVerified('sub-guid');
Svea::assertNothingSent();
```

### `preventStrayRequests()`

[](#preventstrayrequests)

```
Svea::fake()->preventStrayRequests(); // throws on any non-faked call
```

### Generic call assertions

[](#generic-call-assertions)

```
$assertions = Svea::fake();
// run code
$assertions->assertCalled('admin.deliver');
$assertions->assertCalledTimes('admin.deliver', 1);
$assertions->assertNotCalled('checkout.create');
```

### Low-level: Guzzle `MockHandler`

[](#low-level-guzzle-mockhandler)

For integration-style tests that exercise the full HTTP layer without hitting the real API:

```
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Svea\SveaClient;

$mock = new MockHandler([
    new Response(201, [], json_encode(['OrderId' => 12345678, 'Gui' => ['Snippet' => '']])),
]);

$svea = new SveaClient(
    config: ['merchant_id' => 'test', 'shared_secret' => 'secret', 'environment' => 'test'],
    handlerStack: HandlerStack::create($mock),
);

$order = $svea->checkout->create(...);
expect($order->id())->toBe('12345678');
```

---

Advanced Usage
--------------

[](#advanced-usage)

### Retries with exponential backoff

[](#retries-with-exponential-backoff)

```
$svea = new SveaClient([
    'merchant_id'   => '...',
    'shared_secret' => '...',
    'environment'   => 'production',
    'max_retries'   => 2,  // default: 0 (opt-in)
    'timeout'       => 10,
]);
```

`RetryMiddleware` retries on `ConnectionException` and HTTP 429/500/503 with exponential backoff and random jitter. With `max_retries=2`: attempt 1 → ~2 s, attempt 2 → ~4 s.

### Per-request idempotency keys

[](#per-request-idempotency-keys)

Prevent double-captures on queue retries:

```
$deliver = Svea::admin()
    ->order('12345678')
    ->withIdempotencyKey('capture-' . $paymentEvent->id)
    ->deliver(rows: [101, 102]);

$deliver->deliveryId();    // int
$deliver->taskReference(); // string|null — poll via Svea::admin()->task(...)
```

### Custom middleware

[](#custom-middleware)

```
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;

$stack = HandlerStack::create();
$stack->push(Middleware::retry(/* ... */));

$svea = new SveaClient(
    config: config('svea'),
    handlerStack: $stack,
);
```

### Override base URLs

[](#override-base-urls)

Useful for pointing at a local mock server during development:

```
SVEA_CHECKOUT_URL=http://localhost:8080
SVEA_ADMIN_URL=http://localhost:8080
SVEA_SUBSCRIPTIONS_URL=http://localhost:8080
```

---

Error Handling
--------------

[](#error-handling)

```
SveaException                            (base)
├── SveaApiException                     (any non-2xx — carries ->statusCode, ->sveaCode, ->sveaMessage, ->getLastResponse())
│   ├── SveaAuthenticationException      (401 — bad credentials)
│   ├── SveaInvalidRequestException      (400 — validation failed, carries ->errors[])
│   ├── SveaNotFoundException            (404 — order not found)
│   └── SveaRateLimitException           (429 — triggers auto-retry if max_retries > 0)
├── SveaConnectionException              (network failure / timeout — triggers auto-retry)
└── SignatureVerificationException        (inbound webhook HMAC mismatch)

```

```
use Svea\Exceptions\SveaApiException;
use Svea\Exceptions\SveaNotFoundException;

try {
    $order = Svea::admin()->order('12345678')->get();
} catch (SveaNotFoundException $e) {
    // 404 — order not found
} catch (SveaApiException $e) {
    $e->statusCode;        // int
    $e->sveaCode;          // string|null
    $e->sveaMessage;       // string|null
    $e->getLastResponse(); // SveaResponse
}
```

---

Response Objects
----------------

[](#response-objects)

Every API call returns a `SveaResource` — a typed, read-only, array-accessible object:

```
$order = Svea::checkout()->get('12345678');

$order->status();                      // named getter (preferred in typed code)
$order->status;                        // magic property access
$order['status'];                      // ArrayAccess read
$order->successful();                  // bool helper
$order->getLastResponse()->statusCode; // int — raw HTTP status
$order->getLastResponse()->headers;    // array
$order->getLastResponse()->body;       // string
```

> **Read-only:** Attempting `$order['key'] = value` or `unset($order['key'])` throws `\BadMethodCallException`.

---

Package Structure
-----------------

[](#package-structure)

```
src/
├── SveaClient.php          # Main entry point — lazy service properties
├── SveaResource.php        # Base response class: ArrayAccess, magic __get, getLastResponse()
├── Checkout/               # CheckoutService, CheckoutOrder, OrderRow, CheckoutResponse, …
├── Admin/                  # AdminService, AdminOrderRequest, AdminOrderResponse, CreditRequest, …
├── Subscriptions/          # SubscriptionService, SubscriptionBuilder, Subscription, EventType
├── Webhooks/               # Webhook, WebhookService (PSR-7), WebhookEvent, SignatureVerifier
├── Transport/              # SveaConnector (HMAC auth), SveaResponse, RetryMiddleware
├── Contracts/              # CheckoutServiceInterface, AdminServiceInterface, SubscriptionServiceInterface
├── Testing/                # FakeSveaClient, FakeCheckoutService, FakeAdminService, SveaFakeAssertions, …
├── Exceptions/             # SveaException hierarchy (8 classes)
├── Support/                # Conditionable trait (when/unless)
└── Laravel/                # SveaServiceProvider, Svea facade, WebhookService bridge, Commands/, Events/

```

For architecture decisions, internal implementation notes, and contributor setup see [CONTRIBUTING.md](CONTRIBUTING.md).

---

Contributing
------------

[](#contributing)

See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture decisions, internal implementation notes, and development setup.

**License:** MIT

###  Health Score

46

—

FairBetter than 92% of packages

Maintenance98

Actively maintained with recent releases

Popularity17

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity49

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 89.5% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~7 days

Total

4

Last Release

11d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/5223785?v=4)[Mattias Jarestad](/maintainers/mjarestad)[@mjarestad](https://github.com/mjarestad)

---

Top Contributors

[![mjarestad](https://avatars.githubusercontent.com/u/5223785?v=4)](https://github.com/mjarestad "mjarestad (17 commits)")[![looooown2006](https://avatars.githubusercontent.com/u/102905927?v=4)](https://github.com/looooown2006 "looooown2006 (2 commits)")

---

Tags

laravelsdklaravel-packagewebhookspaymentsecommercephp-sdkcheckoutpayment gatewaysveasvea-checkoutsvea-payments

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/nordkit-svea/health.svg)

```
[![Health](https://phpackages.com/badges/nordkit-svea/health.svg)](https://phpackages.com/packages/nordkit-svea)
```

###  Alternatives

[aws/aws-sdk-php

AWS SDK for PHP - Use Amazon Web Services in your PHP project

6.3k532.1M2.5k](/packages/aws-aws-sdk-php)[neuron-core/neuron-ai

The PHP Agentic Framework.

1.9k496.1k32](/packages/neuron-core-neuron-ai)[tempest/framework

The PHP framework that gets out of your way.

2.2k31.1k11](/packages/tempest-framework)[sebdesign/laravel-viva-payments

A Laravel package for integrating the Viva Payments gateway

4849.3k](/packages/sebdesign-laravel-viva-payments)[musahmusah/laravel-multipayment-gateways

A Laravel Package that makes implementation of multiple payment Gateways endpoints and webhooks seamless

882.2k1](/packages/musahmusah-laravel-multipayment-gateways)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
