arbory / omnipay-swedbank-banklink
Swedbank Payment Initiation API driver for Omnipay payment processing library
Package info
github.com/arbory/omnipay-swedbank-banklink
pkg:composer/arbory/omnipay-swedbank-banklink
Requires
- php: ^8.1
- ext-json: *
- ext-openssl: *
- omnipay/common: ^3.0
- symfony/http-client: ^7.4
Requires (Dev)
- mockery/mockery: ^1.5
- omnipay/tests: ^4.0
- phpunit/phpunit: ^10.0
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.comis 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 Afterdate 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-omnipaypackage the gateway is resolved fromconfig/laravel-omnipay.phpautomatically — you do not need to callinitialize()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:
-
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 -puboutIf 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 -
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
-
Wrong environment — sandbox keys used with production URLs or vice versa. Ensure
testModematches your certificates. -
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:
- Outdated bank certificate — download the latest (production or sandbox URL above)
- Wrong environment certificate — ensure sandbox cert is used with sandbox, production with production
Unauthorized Error
- Verify
SWEDBANK_MERCHANT_IDmatches 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
- Swedbank Payment Initiation API V3: https://pi.swedbank.com/developer?version=public_V3
- Sandbox Playground: https://pi-playground.swedbank.com/sandbox/playground
- Production API: https://pi.swedbank.com
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.