nordkit/svea

Modern PHP SDK for Svea Checkout β€” Checkout, Admin, Subscriptions and Webhooks

Maintainers

Package info

github.com/nordkit/svea

pkg:composer/nordkit/svea

Fund package maintenance!

nordkit

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-05-07 22:27 UTC

This package is auto-updated.

Last update: 2026-05-07 22:55:34 UTC


README

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 Total Downloads Tests PHPStan PHP Version Laravel License

πŸ“– Official Svea API documentation: paymentsdocs.svea.com

At a glance

Feature Status
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 & body βœ…
Strict types & 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

Requirements

Dependency Version
PHP ^8.2
guzzlehttp/guzzle ^7.8
illuminate/support (optional) ^11.0 | ^12.0 | ^13.0 β€” required for the Laravel facade and service provider

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

Quick Start

πŸ’‘ Prefer learning by example? Check out 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

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%
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();   // '<script>...</script>' β€” embed in your checkout page
$order->status();    // 'Created' | 'Final' | 'Cancelled'

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()

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

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

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

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

Environment variables

Add these to your .env file:

Variable Required Description
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

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

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

use Svea\Laravel\Svea;

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

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)

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 for other middleware examples.

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

# 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

php artisan svea:subscription:list

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

svea:subscription:get

php artisan svea:subscription:get {id}

svea:subscription:verify

php artisan svea:subscription:verify {id}

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

svea:subscription:update

# 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

php artisan svea:subscription:remove {id}

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

API Reference

Checkout

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();                     // '<div>...</div>' β€” 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

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

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

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

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

Admin

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

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

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

$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<int, array<string, mixed>> β€” all deliveries on the order
$adminOrder->delivery(456);        // array<string, mixed>|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

// 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

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()

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

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:

Subscriptions Task polling
What Svea pushes order lifecycle events to your URL You 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 active Per-operation, URL returned in the response

See Poll a task under Admin for the task-polling API.

Available event types

EventType case Svea event string When 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

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

$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

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

$subscriptions = Svea::subscriptions()->list(); // array<int, Subscription>

// 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

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

$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

Svea::fake()

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' => '<div>...</div>']]),
    '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()

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

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

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' => '<div/>']])),
]);

$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

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

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

use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;

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

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

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

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

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

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, Events/

For architecture decisions, internal implementation notes, and contributor setup see CONTRIBUTING.md.

Contributing

See CONTRIBUTING.md for architecture decisions, internal implementation notes, and development setup.

License: MIT