| Hosts | ePayment spec servers: https://api.vipps.no/epayment (production), https://apitest.vipps.no/epayment (test); Token spec https://api.vipps.no, https://apitest.vipps.no; Test environment page | Live / Test mode (Config::LIVE, Config::TEST) |
POST /accesstoken/get | Token spec fetchAuthorizationTokenUsingPost; Standard authentication page | Headers client_id, client_secret, Ocp-Apim-Subscription-Key, Merchant-Serial-Number, Vipps-System-*; answer access_token, expires_in (a string of seconds; "valid for 1 hour in the test environment and 24 hours in production"). Cached sealed until 5 minutes before expiry; a 401 on a later call drops it and fetches a new one once |
| Common headers | ePayment spec parameters (Merchant-Serial-Number required, Ocp-Apim-Subscription-Key required, Vipps-System-Name/Version/Plugin-Name/Plugin-Version max 30 characters); HTTP headers page ("required for plugins and partners") | Sent on every call: Authorization: Bearer, subscription key, MSN, Vipps-System-Name: opencart, Vipps-System-Version: <OpenCart VERSION>, Vipps-System-Plugin-Name: kaviolabs-opencart-vipps, Vipps-System-Plugin-Version: <extension version> |
POST /epayment/v1/payments | ePayment spec createPayment, schema CreatePaymentRequest; Create page | amount {currency, value} (minor units, integer), paymentMethod.type = WALLET, reference, userFlow = WEB_REDIRECT, returnUrl ("must use the https:// scheme"), paymentDescription (3 to 100). Idempotency-Key header "required: true" (max 50). Answer 201 {redirectUrl, reference}; 400 / 403 / 409 Problem. Not sent: customer (Create page / CustomerPhoneNumber: "Users should never be asked for the phone number"), receipt, profile, expiresAt (long-living payments need approval), merchantLegalLinks |
| Redirect | Create page, WEB_REDIRECT: "It is crucial that this URL is opened directly, without making any changes to it ... Never open the URL in a web view, iframe" | The checkout sets window.location to the redirectUrl as returned |
| Return URL | Create page: "The returnUrl may be opened more than once, including simultaneously. Opening it must not trigger order updates or other side effects. Confirm the payment status and process the order on your backend, with protection against repeated and concurrent processing." | .back only reads the payment back and applies it under the payment lock (GET_LOCK), idempotently |
reference | ePayment spec schema Reference: ^[a-zA-Z0-9-]{8,64}$, unique per sales unit; orderId / reference page: shop prefix + order id + suffix per attempt | Money::reference(): <prefix>-<order id>-<8 hex> (the suffix is random so a reinstalled shop never reuses one) |
| Amount | ePayment spec schema Amount: minor units; minimum NOK 100, DKK 1, EUR 1; maximum 65000000; schema Currency: NOK, DKK, EUR for all merchants (SEK, USD, GBP PSPs only); Create page: "The currency must match the currency for your sales unit" | ISO 4217 exponents in Money::EXPONENT (all 2); the sales unit currency is a setting per mode; other cart currencies are converted with the store's rate of that currency |
GET /epayment/v1/payments/{reference} | ePayment spec getPayment, schema GetPaymentResponse | Reads state, amount, aggregate.{authorizedAmount, capturedAmount, refundedAmount, cancelledAmount}, pspReference, redirectUrl, captureGuaranteedUntil. 404 = unknown reference |
| States | ePayment spec schema State: CREATED, ABORTED, EXPIRED, AUTHORIZED, TERMINATED; "AUTHORIZED ... A payment that has been refunded may have one or more refund events, but the state would be AUTHORIZED" | Webhook::state(): only ABORTED / EXPIRED / TERMINATED end a payment unpaid; anything else (also an unknown value) stays pending; after AUTHORIZED the local status comes from the aggregate (Payments::derive()) |
POST .../capture | ePayment spec capturePayment (Idempotency-Key required, body CaptureModificationRequest {modificationAmount}); Capture page: "Verify that capturedAmount in the response matches the amount you intended to capture - the HTTP status code alone is not sufficient" | Partial and full capture; the answer's aggregate is checked before the operation counts as done |
POST .../refund | ePayment spec refundPayment (Idempotency-Key required, RefundModificationRequest); Refund page ("capturedAmount.value - refundedAmount.value" left) | Partial and full refunds of captured money |
POST .../cancel | ePayment spec cancelPayment (body CancelModificationRequest optional, cancelTransactionOnly: "Only cancel transaction if it has not been authorized"); Cancel page: before authorization the state becomes TERMINATED, after authorization the state stays AUTHORIZED and the uncaptured amount is released; "Cancelling after a partial capture will always release the entire not-yet-captured amount" | Release of reservations (order tab, order status, mismatch / duplicate); cancelTransactionOnly: true when the customer leaves an unapproved payment or a new attempt replaces an open one. The spec lists no Idempotency-Key for cancel; the Refund page's cancel example sends one, so the extension sends one too |
| Idempotency | HTTP headers page: "can safely be retried with the same Idempotency-Key without causing a duplicate"; Errors page: "For some errors (like 423 and 429) the solution is to retry, using the Idempotency-Key header" | Every money call is a row (txn / op) with its key BEFORE the call. Create: no answer = looked up by reference, then sent once more with the SAME key. Capture / refund / cancel without a clear answer: the payment is read back FIRST; if the aggregate shows the operation it is marked done and never resent; otherwise it is sent again with the same key for up to 24 hours, then closed ("check the business portal"). The docs give no lifetime for an Idempotency-Key (flagged), so a late resend never relies on the key alone |
| Errors | ePayment spec schema Problem (RFC 7807: type, title, detail, status, traceId, extraDetails[{name, reason}]) | Api::errorText() shows title, detail, extra details and the trace id to the administrator |
| Webhooks API | Webhooks spec: GET/POST /webhooks/v1/webhooks, DELETE /webhooks/v1/webhooks/{id}; RegisterRequest {url, events}, RegisterResponse {id, secret}; url "must use HTTPS ... Can not be a URL that redirects ... use common ports such as: 80, 443, 8080" | "Register webhook" button; the secret is returned only once, so a registration whose secret we do not hold is deleted and registered again |
| Webhook URL and query parameters | Webhooks spec RegisterRequest.url (no rule against a query string); Webhook authentication page: the signature covers "path and query" | A query string would be allowed, but the extension registers the plain path extension/kv_vipps/notify.php (fixed route, nothing chosen by the request, stable signed path) |
| Events | ePayment Webhooks page table: `epayments.payment.created | aborted | expired | cancelled | captured | refunded | authorized | terminated.v1`; "Subscribe to both" aborted and expired | Registered: authorized, aborted, expired, terminated, cancelled, captured, refunded (Config::EVENTS); created is not needed |
| Webhook payload | ePayment spec schema WebhookEvent (msn, reference, pspReference, name, amount, timestamp, success, ...) | Only reference and msn are used; the state always comes from GET |
| Webhook authentication | Webhook authentication page: content hash = base64(SHA-256(body)) must equal x-ms-content-sha256; string to sign POST\n<pathAndQuery>\n<date>;<host>;<hash> ("\n not \r\n"), HMAC-SHA256 with the registration secret, base64, equals the Signature of Authorization: HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=... | Webhook::verify(), constant-time compares; host and path candidates = the request's Host and URI plus the registered URL (a proxy may change the Host the shop sees; every candidate still needs the secret) |
| Authorization header | HTTP headers page, Special headers: "X-Vipps-Authorization: Only used for Webhooks for some plugins" | Read from HTTP_AUTHORIZATION, REDIRECT_HTTP_AUTHORIZATION, getallheaders(), apache_request_headers(). X-Vipps-Authorization is NOT used (the page says it "Must not be used, and may change or disappear without notice") |
| Retries and delivery order | Webhooks API guide: retried on 4xx/5xx or no answer within 10 s, up to 7 days; "your server needs to accept all preceding requests for a given payment, before any new notifications can be received for the same payment" | 200 when handled or not ours (unknown reference, other MSN: acknowledged so later events are not blocked); 401 for a bad signature; 503 when the read-back failed |
| Polling | Polling guidelines: "Start after 5 seconds, Check every 2 seconds"; ePayment Webhooks page: "you should not rely on webhooks alone" | Payments::due() (5 s, then every 2 s) for the waiting page; the return reads three times 2 s apart; hourly cron for anything left open |
| Capture deadline | captureGuaranteedUntil (GetPaymentResponse, WebhookEvent); Capture page: "between 14 and 180 days" | Shown on the order tab in shop time; the cron logs a warning 3 days ahead |
| Test environment | Test environment page: https://apitest.vipps.no, test sales unit keys, MT test app; "Card payment (CARD) is not available in the test environment" | Test mode; WALLET only |
| Brand | Brand guidelines page + brand book (see research/brand-assets/vipps-mobilepay/SOURCE.md) | Official paymark shipped unchanged (catalog/view/image/*-paymark.svg); default title = local brand; tagline "Pay safely and easily. No fees, no matter the amount." |