nesthus/vipps-php

Unofficial framework-agnostic PHP SDK for Vipps MobilePay: ePayment, Recurring, Login (OIDC) and Webhooks.

Maintainers

Package info

github.com/ekstremedia/vipps-php

pkg:composer/nesthus/vipps-php

Transparency log

Statistics

Installs: 32

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.1 2026-08-15 23:45 UTC

This package is auto-updated.

Last update: 2026-08-16 00:01:14 UTC


README

Unofficial framework-agnostic PHP SDK for Vipps MobilePay: ePayment, Recurring, Login (OIDC) and Webhooks.

CI Latest version Downloads License

Important

This is an unofficial SDK. It is not affiliated with, endorsed or supported by Vipps MobilePay AS. Vipps and MobilePay are trademarks of Vipps MobilePay AS. When presenting the payment option to your users, follow the official brand guidelines: https://brand.vippsmobilepay.com/.

Why this SDK

  • PSR-18 / PSR-17 — bring your own HTTP client; the SDK never picks one, so your timeout and proxy policy applies.
  • PHP 8.3+, final readonly DTOs, native enums, declare(strict_types=1) throughout.
  • No serializer, no annotations, no framework — the only runtime dependencies are PSR interfaces (plus the mbstring extension).
  • No floats for money. Amounts are an Amount value object over integer minor units (øre/cents); float constructors don't exist.
  • Caller-owned idempotency keys. Every mutating call requires one from you, because a key the SDK generated per call protects nothing — you must persist it before the request so you can replay with the same key.
  • Tested webhook signature validation — HMAC verification with recomputed content hashes, constant-time comparison, replay-window enforcement, and leak-free failure reasons.

zaporylie/vipps is the long-standing community alternative and may fit you better; this library exists as a smaller, dependency-light take on the current API generation (ePayment v1, Recurring v3, Webhooks).

Install

composer require nesthus/vipps-php

The SDK depends only on PSR interfaces, so you also need a PSR-18 client and PSR-17 factories. Guzzle provides all of them:

composer require guzzlehttp/guzzle

Warning

Configure timeouts, or a hung call will wedge your worker. Most PSR-18 clients — Guzzle included — wait forever by default, and a payment SDK without deadlines turns one slow upstream call into a stuck process. Construct your client with explicit limits:

$client = new \GuzzleHttp\Client([
    'timeout' => 15,          // whole request, seconds
    'connect_timeout' => 5,   // TCP/TLS handshake, seconds
]);

Setup

All four credential values come from the merchant portal's developer section, per sales unit and per environment — test keys only work against the test host, which is why the environment lives in the config next to the keys.

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Nesthus\Vipps\Environment;
use Nesthus\Vipps\SystemInfo;
use Nesthus\Vipps\Vipps;
use Nesthus\Vipps\VippsConfig;

$config = new VippsConfig(
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret',
    subscriptionKey: 'your-subscription-key',      // Ocp-Apim-Subscription-Key
    merchantSerialNumber: '123456',
    environment: Environment::Test,                // Environment::Production when live
    system: new SystemInfo('acme-webshop', '2.4.1'), // your system's name + version, sent as Vipps-System-* headers
);

$httpFactory = new HttpFactory();                  // PSR-17 request + stream factory

$vipps = new Vipps(
    $config,
    new Client(['timeout' => 15, 'connect_timeout' => 5]),
    $httpFactory,
    $httpFactory,
);

Construction is free — everything inside is lazy. Access tokens are fetched and cached automatically on the first authenticated call (see Token caching).

Recurring quick start

Two rules every integrator trips on: the redirect back to your site proves nothing about approval, and Vipps never bills anyone by itself — your scheduler creates every charge.

use Nesthus\Vipps\Amount;
use Nesthus\Vipps\Recurring\AgreementStatus;
use Nesthus\Vipps\Recurring\ChargeTransactionType;
use Nesthus\Vipps\Recurring\Interval;
use Nesthus\Vipps\Recurring\NewAgreement;
use Nesthus\Vipps\Recurring\NewCharge;
use Nesthus\Vipps\Recurring\Pricing;

// 1. Create the agreement. Mint the idempotency key yourself and persist it
//    BEFORE the request — a key you cannot replay protects nothing.
$created = $vipps->recurring()->createAgreement(new NewAgreement(
    pricing: Pricing::legacy(Amount::fromMajor(49)),          // 49.00 NOK per charge
    interval: Interval::months(1),
    productName: 'Premium',
    merchantRedirectUrl: 'https://shop.example/vipps/return', // where the user lands afterwards
    merchantAgreementUrl: 'https://shop.example/account/subscription', // where they can manage/cancel (required by the Vipps terms)
), $idempotencyKey);

// 2. Persist $created->agreementId next to the key, THEN send the user off:
header('Location: ' . $created->vippsConfirmationUrl);

// 3. Never trust the redirect back as approval — users approve and never
//    return, or return without approving. Poll until the status leaves
//    PENDING (they have 10 minutes), or subscribe to the agreement webhooks.
$agreement = $vipps->recurring()->getAgreement($created->agreementId);

if ($agreement->status === AgreementStatus::Active) {
    // 4. An ACTIVE agreement moves no money. Your scheduled job creates every
    //    charge, at least 1 day before its due date. A missing scheduler looks
    //    exactly like "Vipps stopped charging our customers".
    $chargeId = $vipps->recurring()->createCharge($agreement->id, new NewCharge(
        amount: Amount::fromMajor(49),
        transactionType: ChargeTransactionType::DirectCapture,
        description: 'Premium — September',
        due: new DateTimeImmutable('+30 days'),   // a plain date; Vipps collects some time that day
        retryDays: 5,                             // days Vipps retries a failed collection (0–14)
    ), $chargeIdempotencyKey);
}

Also available: listAgreements() (status filter plus optional pageNumber/pageSize), updateAgreement() (price/text changes), stopAgreement() (final — a stopped agreement can never be reactivated), listCharges() (returns a ChargePage; feed its continuationToken back in to page), getCharge() (use this in webhook and polling flows whenever you have the agreement id), getChargeById() (lookup by charge id alone — Vipps intends it for investigating customer claims, not for automation), cancelCharge(), captureCharge() (for RESERVE_CAPTURE charges — v3 requires an explicit amount even for a full capture) and refundCharge(). Charge money totals (captured/refunded/cancelled) live on Charge->summary.

Pricing models: Pricing::legacy() (fixed price per charge), Pricing::variable() (user-approved ceiling) and Pricing::flexible() (currency only — no price approved up front, every charge carries its own amount; interval: may then be null, since a flexible agreement has no fixed cadence).

ePayment quick start

One-off payments are reserve-then-capture: AUTHORIZED only holds the money; capture() moves it, in full or in parts.

use Nesthus\Vipps\Amount;
use Nesthus\Vipps\Epayment\CreatePayment;
use Nesthus\Vipps\Epayment\PaymentState;

$created = $vipps->epayment()->createPayment(new CreatePayment(
    amount: Amount::fromMajor(249, 50),           // 249.50 NOK
    reference: 'order-2026-000123',               // your permanent id: 8–64 chars of [a-zA-Z0-9-]
    returnUrl: 'https://shop.example/checkout/return',
), $idempotencyKey);

header('Location: ' . $created->redirectUrl);     // null for flows without a browser hop (e.g. PUSH_MESSAGE)

// Back on returnUrl — same rule as Recurring, the redirect proves nothing:
$payment = $vipps->epayment()->getPayment('order-2026-000123');

if ($payment->state === PaymentState::Authorized) {
    // Capture when you deliver — in full or in parts (ship half, capture half).
    // An authorization nobody captures expires on its own; cancel() releases it early.
    $result = $vipps->epayment()->capture('order-2026-000123', Amount::fromMajor(249, 50), $captureKey);

    // capture()/cancel()/refund() return the adjusted payment. Vipps says to
    // verify the capture response before shipping — read the aggregates:
    $result->capturedAmount?->minorUnits;   // 24950 when the full capture landed
}

// Money already captured goes back with refund():
$vipps->epayment()->refund('order-2026-000123', Amount::fromMajor(50), $refundKey);

The reference is your idempotent identity for the whole payment, distinct from the per-request idempotency key: creating a second payment with a used reference is answered with a 409, and that failure is the point — never generate a fresh reference to "get past" it. Note that a fully captured, even fully refunded payment still reports AUTHORIZED — money movement is read from Payment's aggregate amounts and getEvents(), not the state.

Login quick start

A standard OIDC authorization-code flow, discovered at runtime from the well-known document.

use Nesthus\Vipps\Login\AuthorizationRequest;

// 1. Generate state (the CSRF binding) and a PKCE verifier, and store BOTH in
//    the session — they must survive until the redirect returns.
$state = bin2hex(random_bytes(16));
$codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');

$url = $vipps->login()->buildAuthorizationUrl(new AuthorizationRequest(
    redirectUri: 'https://shop.example/auth/vipps/callback', // must exactly match one registered in the portal
    state: $state,
    codeVerifier: $codeVerifier,                             // SDK derives the S256 challenge; verifier never leaves you
));

// 2. On the callback: REFUSE unless the returned `state` matches the session's
//    (the SDK cannot do this — it has no session), then exchange the code with
//    the byte-identical redirectUri and the SAME verifier.
$tokens = $vipps->login()->exchangeCode(
    $_GET['code'],
    'https://shop.example/auth/vipps/callback',
    $codeVerifier,
);

$claims = $tokens->idTokenClaims();                    // sub, and whatever the granted scopes surface
$profile = $vipps->login()->userinfo($tokens->accessToken()); // authorized by the USER's token, not the merchant's

Caution

idTokenClaims() decodes the id token without verifying the JWT signature. That is safe for exactly one reason: this token arrived over TLS directly from Vipps' token endpoint in a confidential-client code exchange, so its origin is already authenticated by the channel. An id token received from anywhere else — a browser redirect, a mobile app, another service — must not be trusted this way; it could be forged freely. Full JWKS signature verification is a deliberate non-goal of v0.1; if you need to accept tokens from untrusted channels, bring a real JWT library.

The claims are not validated either — the SDK performs no OIDC claim checks, so treat them as unvalidated input. If you sent a nonce in the AuthorizationRequest, compare the token's nonce claim against the value in your session before accepting the login (that binding is what stops a token minted for one session being replayed into another — the SDK has no session, so it cannot do this for you). If your acceptance logic reads iss, aud, exp, iat or azp, validate them per the OIDC spec, §3.1.3.7 before using the claims as identity data.

Webhooks

Register a callback URL per sales unit. Vipps caps how many hooks a sales unit may hold, so list-and-reuse (all()) rather than re-registering on every deploy.

$hook = $vipps->webhooks()->register(
    'https://shop.example/hooks/vipps',
    ['epayments.payment.authorized.v1', 'epayments.payment.captured.v1'],
    $idempotencyKey,
);

// ⚠️ $hook->secret() is shown EXACTLY ONCE — Vipps never re-reveals it, and
// all() returns id/url/events only. Persist it (encrypted, next to $hook->id)
// before doing anything else. If storage fails, delete() and register() again.
// The secret is a method, not a property: dumping the object (print_r,
// var_export, var_dump) shows ***redacted*** instead of signing material.

Verify every inbound delivery before trusting a byte of it:

use Nesthus\Vipps\Webhooks\SignatureValidator;
use Nesthus\Vipps\Webhooks\WebhookRequest;

$result = (new SignatureValidator())->validate(
    WebhookRequest::fromPsr7($serverRequest),   // PSR-7 ServerRequestInterface
    $secret,                                     // the one you persisted at registration
);

if (! $result->valid) {
    // $result->reason is a stable snake_case slug ("signature_mismatch",
    // "stale_timestamp", …) that never contains signing material — safe to log verbatim.
    http_response_code(401);
    exit;
}

No PSR-7? Construct WebhookRequest directly with the method, the path+query as sent on the wire, the Host header value, the exact raw body bytes (never re-encoded from a decoded payload — the hash covers bytes, not meaning), and the three signature headers.

Token caching

Merchant access tokens are fetched and refreshed automatically; you never handle them. The default cache is in-memory — one token per process, which is fine for classic per-request PHP. In long-running or multi-worker setups (queues, Octane, Swoole), share one token through any PSR-16 cache instead of letting every worker mint its own:

use Nesthus\Vipps\Auth\Psr16TokenCache;

$vipps = new Vipps(
    $config,
    $client,
    $httpFactory,
    $httpFactory,
    tokenCache: new Psr16TokenCache($yourPsr16Cache),   // Redis, APCu, your framework's store
);

If Vipps ever answers 401 on a token that should have been valid (revoked keys, clock trouble), $vipps->tokens()->forget() drops the cached token so the next call fetches fresh.

Errors

Everything the SDK throws implements the Nesthus\Vipps\Exceptions\VippsException marker interface.

use Nesthus\Vipps\Exceptions\VippsApiException;
use Nesthus\Vipps\Exceptions\VippsConfigException;
use Nesthus\Vipps\Exceptions\VippsException;

try {
    $vipps->epayment()->capture($reference, $amount, $key);
} catch (VippsApiException $e) {
    $e->status;    // HTTP status; 0 when the transport itself failed (DNS, TLS, timeout)
    $e->details;   // Vipps' decoded error body (problem+json when available)
    $e->traceId;   // quote this in a Vipps support case
} catch (VippsConfigException $e) {
    // a value YOUR code built is invalid — bad reference format, negative
    // amount, empty credential — thrown before any request goes out
}

Modules never inspect status codes themselves: any non-2xx from Vipps becomes a VippsApiException at the transport. Its message carries method, path, status and Vipps' own title/detail — never request headers or bodies, so credentials can't leak through your exception logs.

Testing your integration

The suite under tests/ doubles as living documentation of every endpoint's exact wire shape — URL, headers, JSON body — and the pattern is portable: a queue-and-record PSR-18 fake (tests/Support/FakeHttpClient.php, ~60 lines, copy it into your own suite) plus Guzzle's PSR-17 HttpFactory. No HTTP, no mocking framework; the real transport runs down to the PSR-7 boundary.

use GuzzleHttp\Psr7\HttpFactory;
use Nesthus\Vipps\Amount;
use Nesthus\Vipps\Epayment\CreatePayment;
use Nesthus\Vipps\Epayment\EpaymentApi;
use Nesthus\Vipps\Http\ApiTransport;
use Nesthus\Vipps\Tests\Support\FakeHttpClient;
use Nesthus\Vipps\VippsConfig;

$http = new FakeHttpClient();
$factory = new HttpFactory();

$api = new EpaymentApi(new ApiTransport(
    $http,
    $factory,
    $factory,
    new VippsConfig('client-id', 'client-secret', 'subscription-key', '123456'),
));

$http->queueJson(201, ['reference' => 'order-2026-000123', 'redirectUrl' => 'https://landing.vipps.no?token=abc']);

$created = $api->createPayment(new CreatePayment(
    amount: Amount::fromMajor(49),
    reference: 'order-2026-000123',
    returnUrl: 'https://shop.example/return',
), 'idem-create-1');

$request = $http->lastRequest();   // full PSR-7 request — assert method, URI, Idempotency-Key, body

For end-to-end testing against real infrastructure, use Vipps' apitest environment: Environment::Test points at https://apitest.vipps.no, a full sandbox with its own merchant keys (from the test tab of the merchant portal's developer section) and test users. Test keys never work against production and vice versa.

Development

No local PHP needed — everything runs in throwaway containers:

docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app composer:2 install
docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/pest
docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/pint --test src tests
docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/phpstan analyse --no-progress

CI runs the same three checks (pint, phpstan level max, pest) on PHP 8.3 and 8.4.

Versioning & license

This is a 0.x release: the public API may still move between minor versions — pin accordingly and read CHANGELOG.md before upgrading. Once the surface has survived real-world use, 1.0 freezes it under semantic versioning.

MIT — see LICENSE.

Related: nesthus/vipps-laravel wraps this SDK for Laravel (config, container bindings, facades).