arbory/omnipay-swedbank-banklink

Swedbank Payment Initiation API driver for Omnipay payment processing library

Maintainers

Package info

github.com/arbory/omnipay-swedbank-banklink

pkg:composer/arbory/omnipay-swedbank-banklink

Statistics

Installs: 7 684

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

4.0.1 2026-03-20 14:45 UTC

README

Swedbank Payment Initiation API V3 driver for the Omnipay PHP payment processing library

Omnipay is a framework agnostic, multi-gateway payment processing library for PHP. This package implements Swedbank Payment Initiation API V3 support for Omnipay.

Installation

composer require arbory/omnipay-swedbank-banklink

Or add it to composer.json manually:

{
    "require": {
        "arbory/omnipay-swedbank-banklink": "^4.0"
    }
}

Certificate Setup

How Keys Work

Swedbank V3 uses asymmetric RSA cryptography. There are two separate key pairs:

Key Who generates/provides it What to do with it
Merchant private key You generate it Keep secret on your server, never share
Merchant public key You generate it (from private key) Upload to Swedbank merchant portal
Bank certificate Provided by Swedbank Download and use to verify bank responses

ℹ️ The merchant private key is always generated by you — Swedbank never provides it. The playground at pi-playground.swedbank.com is for testing API flows only, not for issuing keys.

Step 1 — Generate Your Merchant Key Pair

Run these commands once per environment:

mkdir -p payment_certificates/swedbank_prod

# Generate 4096-bit RSA private key
openssl genrsa -out payment_certificates/swedbank_prod/merchant_private.key 4096

# Extract the public key from it
openssl rsa -in payment_certificates/swedbank_prod/merchant_private.key \
  -pubout -out payment_certificates/swedbank_prod/merchant_public.key

# Secure the private key
chmod 600 payment_certificates/swedbank_prod/merchant_private.key

# Verify the private key is valid
openssl rsa -in payment_certificates/swedbank_prod/merchant_private.key -noout -check
# Expected output: RSA key ok

Step 2 — Upload Your Public Key to Swedbank

Log in to the Swedbank merchant portal and upload the contents of merchant_public.key (the -----BEGIN PUBLIC KEY----- block) to your merchant account settings. Swedbank will associate it with your Merchant ID.

For sandbox testing, upload to the sandbox portal. For production, upload to the production portal.

Step 3 — Download the Bank Certificate

Swedbank's public certificate is used to verify signatures on bank responses.

# Production bank certificate
curl -o payment_certificates/swedbank_prod/bankCertificate_009.txt \
  https://pi.swedbank.com/public/resources/bank-certificates/009

# Sandbox bank certificate (for testing)
mkdir -p payment_certificates/swedbank_sandbox
curl -o payment_certificates/swedbank_sandbox/bankCertificate_009.txt \
  https://pi-playground.swedbank.com/public/resources/bank-certificates/009

# Check certificate details and expiry
openssl x509 -in payment_certificates/swedbank_prod/bankCertificate_009.txt -noout -text | grep -E "Subject:|Not After"

The production bank certificate (as of 2024) is issued to Banklink Host by Swedbank G3 Issuing CA, valid until 2027-08-06.

Certificate Format

All files are in standard PEM format:

-----BEGIN PRIVATE KEY-----   ← merchant_private.key
-----BEGIN PUBLIC KEY-----    ← merchant_public.key
-----BEGIN CERTIFICATE-----   ← bankCertificate_009.txt (X.509 certificate)

Important Notes

⚠️ Never share or commit your merchant private key. It must only exist on your server.

  • The merchant public key is what you register with Swedbank — not the private key
  • Use the correct environment certificates: sandbox keys with sandbox, production keys with production
  • The bank certificate expires periodically — check Not After date and re-download when needed

Add to .gitignore:

payment_certificates/
storage/certificates/

Environment & Laravel Configuration

.env

SWEDBANK_MERCHANT_ID=SANDBOX_RSA
SWEDBANK_TEST_MODE=true
SWEDBANK_COUNTRY=LV
SWEDBANK_PRIVATE_KEY_PATH=/path/to/payment_certificates/swedbank_sandbox/merchant_private.key
SWEDBANK_BANK_PUBLIC_KEY_PATH=/path/to/payment_certificates/swedbank_sandbox/bankCertificate_009.txt
SWEDBANK_ALGORITHM=RS512
SWEDBANK_DEBUG_LOGGING=false
SWEDBANK_RETURN_URL=https://yourdomain.com/payments/complete-purchase/swedbank-banklink
SWEDBANK_GATEWAY_URL=https://pi-playground.swedbank.com/sandbox/

For production:

SWEDBANK_MERCHANT_ID=YOUR_PRODUCTION_MERCHANT_ID
SWEDBANK_TEST_MODE=false
SWEDBANK_COUNTRY=LV
SWEDBANK_PRIVATE_KEY_PATH=/path/to/payment_certificates/swedbank_prod/merchant_private.key
SWEDBANK_BANK_PUBLIC_KEY_PATH=/path/to/payment_certificates/swedbank_prod/bankCertificate_009.txt
SWEDBANK_ALGORITHM=RS512
SWEDBANK_DEBUG_LOGGING=false
SWEDBANK_RETURN_URL=https://yourdomain.com/payments/complete-purchase/swedbank-banklink
SWEDBANK_GATEWAY_URL=https://pi.swedbank.com

config/laravel-omnipay.php

'swedbank-banklink' => [
    'driver' => 'SwedbankBanklink',
    'options' => [
        'merchantId'        => env('SWEDBANK_MERCHANT_ID'),
        'country'           => env('SWEDBANK_COUNTRY', 'LV'),
        'privateKeyPath'    => env('SWEDBANK_PRIVATE_KEY_PATH'),
        'bankPublicKeyPath' => env('SWEDBANK_BANK_PUBLIC_KEY_PATH'),
        'algorithm'         => env('SWEDBANK_ALGORITHM', 'RS512'),
        'testMode'          => env('SWEDBANK_TEST_MODE', true),
        'returnUrl'         => env('SWEDBANK_RETURN_URL'),
        'baseUrl'           => env('SWEDBANK_GATEWAY_URL'),
        'debugLogging'      => env('SWEDBANK_DEBUG_LOGGING', false),
    ],
],

Usage

Step 1 — Initialise the Gateway

use Omnipay\Omnipay;

$gateway = Omnipay::create('SwedbankBanklink');
$gateway->initialize([
    'merchantId'        => env('SWEDBANK_MERCHANT_ID'),
    'country'           => env('SWEDBANK_COUNTRY', 'LV'),
    'privateKeyPath'    => env('SWEDBANK_PRIVATE_KEY_PATH'),
    'bankPublicKeyPath' => env('SWEDBANK_BANK_PUBLIC_KEY_PATH'),
    'algorithm'         => env('SWEDBANK_ALGORITHM', 'RS512'),
    'testMode'          => env('SWEDBANK_TEST_MODE', true),
    'baseUrl'           => env('SWEDBANK_GATEWAY_URL'),
    'debugLogging'      => env('SWEDBANK_DEBUG_LOGGING', false),
]);

// Alternative: pass raw key content instead of file paths
// $gateway->initialize([
//     'merchantId'    => '...',
//     'country'       => 'LV',
//     'privateKey'    => file_get_contents('/path/to/private.key'),
//     'bankPublicKey' => file_get_contents('/path/to/bank-certificate.pem'),
//     'testMode'      => true,
//     'debugLogging'  => false,
// ]);

When using the laravel-omnipay package the gateway is resolved from config/laravel-omnipay.php automatically — you do not need to call initialize() manually.

Provider Resolution

The provider parameter passed to purchase() must be a valid bank BIC code (e.g. HABALT22). In applications where the user selects a bank via a payment_type slug (stored in a BankLink model or similar), a resolver maps that slug to the correct BIC at runtime.

ProviderResolver

ProviderResolver is a static utility class in this package that handles the mapping:

Scenario Behaviour
payment_type is empty Returns ProviderResolver::DEFAULT_BIC (HABALV22)
Custom resolver registered, returns a BIC Uses the resolved BIC
Custom resolver registered, returns null Falls back to the raw payment_type value
No resolver registered Returns the raw payment_type value directly

Registering a Custom Resolver (Laravel)

Register the resolver once during application boot in AppServiceProvider:

// app/Providers/AppServiceProvider.php

use App\Models\BankLink;
use Omnipay\SwedbankBanklink\Utils\ProviderResolver;

public function boot(): void
{
    // ...

    ProviderResolver::setResolver(function (string $paymentType): ?string {
        $bankLink = BankLink::where('payment_type', $paymentType)->first();
        return $bankLink?->bic;
    });
}

This looks up the BankLink record matching the order's payment_type and returns its bic column value. If no record is found, null is returned and ProviderResolver falls back to using the raw payment_type string.

The resolver is called automatically by SwedbankBanklinkHandler::getProvider() when building purchase arguments — no manual call is needed in your controllers.

Step 2 — Get Available Payment Providers

$response = $gateway->getProviders()->send();

if ($response->isSuccessful()) {
    $providers = $response->getEnabledProviders();
    // Each provider has a BIC code, e.g. 'HABALT22' (Swedbank Latvia)
}

Step 3 — Initiate a Payment

Store the returned transaction ID in your session/database before redirecting the customer.

$response = $gateway->purchase([
    'amount'          => '99.99',
    'currency'        => 'EUR',                 // Only EUR supported
    'locale'          => 'lv',                  // en, et, lv, lt, or ru
    'description'     => 'Order #12345',        // Max 140 chars (unstructured)
    // 'reference'    => 'RF18539007547034',    // ISO11649 structured reference (alternative)
    'provider'        => 'HABALT22',            // BIC from getProviders()
    'returnUrl'       => 'https://yourdomain.com/payment/return',
    'notificationUrl' => 'https://yourdomain.com/payment/webhook',
])->send();

if ($response->isRedirect()) {
    $transactionId = $response->getTransactionReference(); // Save this!
    session(['swedbank_transaction_id' => $transactionId]);

    return $response->redirect(); // Redirect customer to bank
}

Step 4 — Handle the Return URL

When the customer is redirected back, always call fetchTransaction — the redirect itself carries no payment confirmation.

// PaymentReturnController.php
$transactionId = session('swedbank_transaction_id');

$response = $gateway->fetchTransaction([
    'transactionReference' => $transactionId,
])->send();

if ($response->isSuccessful()) {
    // EXECUTED or SETTLED — payment confirmed, safe to fulfill the order
    $details = $response->getPaymentDetails();
    /*
     * $details keys: transactionId, status, amount, currency, paymentType,
     *   debtor, debtorAccount, debtorBic, creditor, creditorAccount, creditorBic,
     *   reference, referenceType, description, endToEndIdentification,
     *   createdAt, statusUpdatedAt, statusCheckedAt, errorDetails, errorLabels, ...
     */
} elseif ($response->isPending()) {
    // Still processing — show a "payment is being processed" page
    // Rely on the webhook notification (Step 5) for the final status update
} elseif ($response->isCancelled()) {
    // Customer cancelled at the bank
} elseif ($response->isFailed()) {
    $reason = $response->getRejectionReason(); // from errorDetails / errorLabels
}

Status Polling

Payment Status Reference

Status Group Description
NOT_INITIATED ⏳ Pending Transaction registered but not yet started
INITIAL ⏳ Pending User initiated the payment
STARTED ⏳ Pending Payment initiation started
IN_PROGRESS ⏳ Pending Awaiting final status from bank
IN_AUTHENTICATION ⏳ Pending Awaiting user authentication
IN_CONFIRMATION ⏳ Pending Awaiting user confirmation
IN_DOUBLE_SIGNING ⏳ Pending Awaiting second signer
UNKNOWN ⏳ Pending Temporary unknown state
EXECUTED ✅ Success Payment successfully initiated
SETTLED ✅ Success Payment settled (only with Swedbank settlement account)
ABANDONED ❌ Failed Not initiated within 1 hour
FAILED ❌ Failed Payment initiation failed
CANCELLED_BY_USER ❌ Failed User cancelled at the bank
EXPIRED ❌ Failed No final status within expected timeframe

Background Job Polling

For payments still isPending() after the return, poll in a background job with exponential backoff:

// App\Jobs\PollSwedbankPayment.php (simplified)
public function handle(): void
{
    $response = $this->gateway->fetchTransaction([
        'transactionReference' => $this->transactionId,
    ])->send();

    if ($response->isSuccessful()) {
        // Mark order as paid
    } elseif ($response->isFailed() || $response->isCancelled()) {
        // Mark order as failed
    } elseif ($response->isPending()) {
        // Re-dispatch with increasing delay
        self::dispatch($this->gateway, $this->transactionId)
            ->delay(now()->addSeconds(15));
    }
}

⚠️ In production, never use a blocking sleep() loop. Use queued jobs with delays and a maximum timeout (e.g., 10–15 minutes total).

Bank Webhook Notifications

Swedbank sends a server-to-server HTTP POST to your notificationUrl when a payment reaches a final status. This is the most reliable confirmation method — it arrives even if the customer closes their browser before being redirected back.

What Swedbank Sends

The notification body is a signed JSON payload (StatusResponseV3), identical to the fetchTransaction response. The signature is in the x-jws-signature HTTP header.

POST /payment/webhook HTTP/1.1
Content-Type: application/json
x-jws-signature: eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJSUzUxMiIsImlhdCI6...

{
    "transactionId": "abc123...",
    "status": "EXECUTED",
    "amount": "99.99",
    "currency": "EUR",
    "debtorBic": "HABALT22",
    ...
}

Route

// routes/web.php or routes/api.php
Route::post('/payment/webhook', [PaymentWebhookController::class, 'handle'])
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Controller

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Omnipay\SwedbankBanklink\Utils\JwsSignature;

class PaymentWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // 1. Read raw body — exact bytes are required for signature verification
        $rawBody   = $request->getContent();
        $jwsHeader = $request->header('x-jws-signature');

        if (empty($jwsHeader)) {
            \Log::warning('Swedbank webhook: missing x-jws-signature header');
            return response('Signature missing', 400);
        }

        // 2. Verify signature
        //    Pass 0 to skip the freshness check — Swedbank retries for up to 24 hours,
        //    so the iat timestamp in the JWS header can be many hours old.
        $bankPublicKey = file_get_contents(env('SWEDBANK_BANK_PUBLIC_KEY_PATH'));

        try {
            $isValid = JwsSignature::verify($jwsHeader, $rawBody, $bankPublicKey, 0);
        } catch (\Exception $e) {
            \Log::error('Swedbank webhook signature error: ' . $e->getMessage());
            return response('Signature error', 400);
        }

        if (!$isValid) {
            \Log::warning('Swedbank webhook: invalid signature');
            return response('Invalid signature', 400);
        }

        // 3. Decode payload
        $data          = json_decode($rawBody, true);
        $transactionId = $data['transactionId'] ?? null;
        $status        = $data['status'] ?? null;

        if (!$transactionId || !$status) {
            return response('Missing fields', 400);
        }

        // 4. Handle status — guard against double-processing (Swedbank may retry)
        if (in_array($status, ['EXECUTED', 'SETTLED'], true)) {
            // Fulfill the order, e.g.:
            // Order::where('payment_id', $transactionId)->update(['status' => 'paid']);
        } elseif (in_array($status, ['ABANDONED', 'FAILED', 'CANCELLED_BY_USER', 'EXPIRED'], true)) {
            // Mark order as failed
        }
        // Pending status notifications are informational — no action needed

        // 5. Always acknowledge with 200; Swedbank retries on any non-2xx response
        return response('OK', 200);
    }
}

Webhook vs. Return URL — Which to Trust?

Return URL Webhook Notification
Triggered by Customer browser redirect Swedbank server POST
Reliability Can be missed (browser closed) Reliable, retried on failure
Use for Show result to customer Fulfillment (update order status)
Needs status poll? Yes — always call fetchTransaction No — payload contains final status

Best practice: use the webhook as the authoritative source for order fulfillment; use the return URL only to show the customer a result page.

maxAgeSeconds — Synchronous vs. Asynchronous

JwsSignature::verify() checks the iat (issued-at) timestamp inside the JWS header. Use a strict timeout for synchronous API responses and disable it for webhooks:

// Synchronous API response — enforce freshness
JwsSignature::verify($jws, $body, $bankPublicKey, 120); // max 120 seconds old

// Asynchronous webhook notification — disable freshness check
JwsSignature::verify($jws, $body, $bankPublicKey, 0);   // 0 = disabled

API Reference

Required Parameters for Payment Initiation

Parameter Description
amount Positive decimal, up to 2 decimal places (e.g., 10.00)
currency Only EUR supported
locale UI language: en, et, lv, lt, or ru
returnUrl HTTPS URL for user redirect after payment (max 2048 chars)
notificationUrl HTTPS URL for server-to-server status notifications (max 2048 chars)
provider Bank BIC code (e.g., HABALT22, HABAEE2X, RIKOLV23)

Optional Parameters

Parameter Description
description Unstructured payment reference (max 140 chars)
reference Structured reference number (ISO11649 format)

Supported Countries & Banks

  • Latvia (LV): HABALT22, UNLAEE2X, RIKOLV23, and others
  • Estonia (EE): HABAEE2X, DEUTEE2X, SWEDEE3X, and others
  • Lithuania (LT): HABALT22, CBVILT2X, VILULT2X, and others

Use getProviders() to get the full list for your country.

Signature Algorithms

RS512 (default), ES256, ES256K, ES384, ES512

Logging

When debugLogging is set to true, all API requests and responses are logged via Laravel's Log::channel('payments') facade. This includes:

  • Outgoing request URL, method, body, and JWS signature
  • Incoming response status, body, and signature validation result
  • Signature verification failures (with error details)
  • HTTP-level exceptions

Logs are written at the debug level, so make sure the channel's level is set to debug (or lower) in your environment.

Configuring the payments Log Channel

Add the following channel to config/logging.php:

'payments' => [
    'driver' => 'single',
    'path'   => storage_path('logs/payments.log'),
    'level'  => 'debug',
],

What Gets Logged

Event Level Key fields
Outgoing API request debug method, url, body, merchant_id, country, algorithm
Successful API response debug status, response_data, signature_valid
Response signature failure debug status, body, signature_valid: false, signature_error
Missing signature header debug status, body, signature_error: "No signature header found"
HTTP exception debug error: true, error_message, error_code, exception_class

Sensitive fields such as the full JWS signature are truncated to 100 characters in log output. The raw private key is never logged.

Troubleshooting

INVALID_SIGNATURE errors

Error from Swedbank: Invalid signature

The request signature failed verification on Swedbank's side. Common causes:

  1. Wrong merchant private key — the key on your server doesn't match the public key registered with Swedbank

    # Check what public key your private key produces and compare with Swedbank portal
    openssl rsa -in payment_certificates/swedbank_prod/merchant_private.key -pubout

    If mismatched, generate a new pair and re-upload:

    openssl genrsa -out payment_certificates/swedbank_prod/merchant_private.key 4096
    openssl rsa -in payment_certificates/swedbank_prod/merchant_private.key \
      -pubout -out payment_certificates/swedbank_prod/merchant_public.key
    # Upload merchant_public.key contents to your Swedbank merchant portal
  2. Expired bank certificate — download the latest:

    curl -o payment_certificates/swedbank_prod/bankCertificate_009.txt \
      https://pi.swedbank.com/public/resources/bank-certificates/009
    openssl x509 -in payment_certificates/swedbank_prod/bankCertificate_009.txt -noout -dates
  3. Wrong environment — sandbox keys used with production URLs or vice versa. Ensure testMode matches your certificates.

  4. Request body encoding — JSON must be encoded with JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE (handled automatically by this package).

Invalid Response Signature

Error in response: Invalid response signature from bank

The response signature verification failed on the client side:

  1. Outdated bank certificate — download the latest (production or sandbox URL above)
  2. Wrong environment certificate — ensure sandbox cert is used with sandbox, production with production

Unauthorized Error

  • Verify SWEDBANK_MERCHANT_ID matches your Swedbank registration exactly
  • Verify certificates are for the correct environment
  • Check whether Swedbank requires IP whitelisting for your account

Bank Certificate Expired

openssl x509 -in payment_certificates/swedbank_prod/bankCertificate_009.txt -noout -text | grep "Not After"

Re-download if expired:

curl -o payment_certificates/swedbank_prod/bankCertificate_009.txt \
  https://pi.swedbank.com/public/resources/bank-certificates/009

Debugging

$response = $gateway->purchase($options)->send();
if (!$response->isSuccessful()) {
    $message = $response->getMessage(); // Contains detailed error info
    error_log($message);
}

Responses carry _signature_error / _signature_invalid keys in the data array when signature verification fails, providing additional diagnostic detail.

External Links

Support

If you are having general issues with Omnipay, we suggest posting on Stack Overflow. Be sure to add the omnipay tag so it can be easily found.

If you believe you have found a bug, please report it using the GitHub issue tracker.