Skip to content

Developer notes

Item Value
Add-on id kv_scalapay (scheme 3.0, payment processor kv_scalapay.php)
Tables ?:kv_scalapay_accounts (API keys sealed with Secret, AES-256-GCM; widget tokens), ?:kv_scalapay_transactions (one row per Scalapay order), ?:kv_scalapay_ops (capture, delay, void, refund, share), ?:kv_scalapay_jobs, ?:kv_scalapay_log. Accounts, transactions and ops are kept on uninstall (Retention, kind payment) unless “Delete payment records on uninstall” is on.
PHP hooks change_order_status_post (capture / void of delayed authorizations, refund at status), prepare_checkout_payment_methods (hide when not payable), init_templater_post (upgrade sync, only while pending)
Template hooks storefront products:main_price post (widget), checkout:cart_content_bottom_right_buttons pre (widget), index:styles post; admin orders:tabs_extra post (Scalapay tab)
Controllers frontend kv_scalapay.back (return bounce, no_session=Y), kv_scalapay.webhook (POST, ?c=<account>), kv_scalapay.cron (secret key); backend kv_scalapay.manage/connection/log/jobs, tab actions capture/void/refund/sync, console kv_scalapay.cron; payment_notification.return&payment=kv_scalapay (processor script)
Test hook $config['kv_scalapay_base_url'] in config.local.php (loopback hosts only) points the client at tests/mock-server/server.js; the e2e scripts refuse to run otherwise

All read 2026-11-08 from the official developer site (copies in the build workspace of the OpenCart port scratchpad/oc-scalapay/apidocs/; the site publishes an index at https://developers.scalapay.com/llms.txt and every page as Markdown with its OpenAPI definition):

Every call, header and field, with its source

Section titled “Every call, header and field, with its source”
What Source Used for / fields
Hosts Arch, Endpoints: Production https://api.scalapay.com, Sandbox https://integration.api.scalapay.com (“supports Fake Cards”) Config::LIVE, Config::TEST
Authentication Arch: “Every API endpoint requires your secret API key to be included as a bearer token in the Authorization header”, Authorization: Bearer <key>, 401 on failure; separate sandbox and production keys “usually prefixed with sp_” Client\Api::authHeaders(); one sealed key per mode and account (Accounts)
Public test key API Simulator: “for our online standard, delay and multicapture calls our test API key is qhtfs87hjnc12kkos”; OC plugin: “Staging/Test API key: qhtfs87hjnc12kkos” Config::PUBLIC_TEST_KEY, used in sandbox mode when no own key is stored (tests use it only against the local mock)
Errors Arch, Error Response Structure: errorCode, errorId, message, httpStatusCode; “rely on the error code and HTTP status code”; 2XX ok, 4XX invalid, 5XX “unexpected error on our side”; Capture/Delay/Void/Refund 400 example api_validationerror with message.errors[{field, messages}]; 409 conflicting_operation_in_progress “Try again later.” Client\Api::errorFrom(); retryable = no answer, 5XX, 429, 409 conflicting_operation_in_progress (Client\Api::call())
Idempotency-Key Create, Capture, Delay, Void, Refund, Update: header Idempotency-Key, “Idempotency key used for retrying requests. Can be used for retrying failed requests.” 32-hex key stored BEFORE every money call (transactions.idem_key, ops.idem_key); a retry re-sends the same body with the same key
POST /v2/orders Create: required totalAmount, consumer, items, merchant, shipping; answer token, expires, checkoutUrl (“redirects the customer to Scalapay Checkout, where the payment is authorized”) Client\Api::createOrder(), Payload::order()
totalAmount, amounts Create, schema amount {amount, currency}, “Monetary value. Example: 187.95.” (string) Money::obj(): decimal string with the ISO 4217 exponent of EUR; internally integers in minor units
consumer Create: phoneNumber (“Customer phone number with country code”), givenNames, surname, email, all required From the order (phone, else shipping / billing phone; Payload::phone()); an order without a phone is refused before any call
shipping / billing Create: shipping required phoneNumber, countryCode (iso-alpha-2), name, postcode, suburb, line1; billing optional, same fields Shipping address (s_) of the order, else the billing address (b_); billing when the order has one
items Create: quantity (integer), price, name, category, sku required; gtin, subcategory, brand, pageUrl, imageUrl optional Order products: unit price as charged, main category name (else “General”), product code (else product id)
discounts, shippingAmount, taxAmount Create: optional subtotal_discount as a discount; shipping_cost; tax_subtotal (taxes added on top); a rest (rounding, surcharge, gift certificates) as “Other charges” / “Other reductions”, so the breakdown always adds up
merchant Create: redirectConfirmUrl, redirectCancelUrl required Both point to kv_scalapay.back&no_session=Y (cancel adds &c=1); the return changes nothing by itself, the order is read back
merchantReference Create: “Merchant reference for tracking the order in your system”; Hooks data model: “The correlation id from the merchant, typically the order id” <prefix>-<order id> (Money::reference(), setting default cs)
type Create: enum online, offline, link online
product Create: enum pay-in-3, pay-in-4, later, pay-now-standalone; “Could be empty” One CS-Cart payment method per product (processor parameter product); “any” = field omitted
frequency, orderExpiryMilliseconds Create: frequency “useful for pay later products. Do not include when using pay-in-3, pay-in-4”; expiry “determined by your Scalapay contract” Not sent (contract defaults)
GET /v2/payments/{token} Get: examples carry token, created, status, captureStatus (captured / pending), product, totalAmount, orderDetails, lastEligibilityFeedback (e.g. DECLINED_FRAUD); unknown token 422 invalid_token “Order null not found.” Client\Api::getOrder(); the only source of the state (PaymentService::sync())
Statuses Hooks: created, authorized, charged, refunded, expired; data model: “The list of statuses may change in the future, so it is not advised to hardcode the set of values to expect” Webhook::state(): authorized, charged, refunded, expired are acted on; anything else stays pending (never cancelled)
GET /v2/payments/references Refs: query orderToken, merchantOrderReference, merchantProcessorReference; “maximum of 100 matching orders, sorted by creation date (newest first)”; answer [{orderToken, merchantOrderReference, merchantProcessorReference, createdAt}] A create call without an answer: the token is found by the merchant reference (PaymentService::adopt(), which also requires the same amount)
POST /v2/payments/capture Capture: body token (required), amount, merchantReference; “You can capture an amount up to the total specified during order creation”; answer {token, status, totalAmount, capturedAt}, example status: "APPROVED"; API calls page: capture “is mandatory to correctly finalise the order” Client\Api::capture(); success only on APPROVED, any other value is read back and only charged counts
POST /v2/payments/{token}/delay Delay: authorizationExpiryMilliseconds required, default 432000000; answer {token, status, authorizationExpiryAt} (example AUTHORIZED); Flows: “You can set to delay the capture up to 5 days after the order creation”; “Outstanding authorizations will be automatically VOIDED once the authorizationExpiryMilliseconds time passes” Delayed modes; setting 1 to 5 days; capture_until from authorizationExpiryAt
POST /v2/payments/{token}/void Void: “Void any authorizations for the order”; body merchantReference; answer {token, merchantReference, voidedAt} Voids delayed authorizations (cancel status or by hand) and authorizations that must not be booked (mismatch, duplicate, cancelled order)
POST /v2/payments/{token}/refund Refund: refundAmount required, merchantRefundReference; answer {token, amount, merchantReference, merchantRefundReference, refundToken, refundedAt} Full and partial refunds; merchantRefundReference = oc<order>-r<op id>; the answered amount is compared with the requested one
Webhook URL Listener authentication setup: “login to the Partner portal and, in the developers section, fill in the field” Webhook Url Shown on the Connection page (index.php?dispatch=kv_scalapay.webhook&c=<account>); no registration API exists
Webhook body Hooks data model: totalAmount, status, orderToken, merchantReference; examples (number totalAmount) Only orderToken is used; the state comes from GET
Webhook signature Messages validation: headers x-scalapay-hmac-v1, x-scalapay-timestamp; raw string {version}:{timestamp}:{JSON payload}; “Secret: Your merchant’s API key. Algorithm: SHA-256. Digest: Hexadecimal”; PHP example $version = 'V1'; “convert headers to lowercase” Webhook::verify() with hash_equals, key of the account of the URL (?c=), both modes tried when the token is unknown; headers read with the PHP-FPM fallbacks of Webhook::headers()
Webhook retries Connection issues: success = HTTP 200; on failure “retried with an exponential backoff”, “may not be processed by your servers in sequence” 200 when handled or not ours, 401 bad signature, 400 unreadable, 503 when the read-back failed; order-independent because every hook only triggers a read-back
Widget script Widget installation: <script type="module" src="https://cdn.scalapay.com/widget/scalapay-widget-loader.js?version=V5"> Widget::SCRIPT
Widget tag Widget configuration: merchant-token (required), amount-selectors or amount, min-amount, max-amount, locale (en, it, pt, de, fr, es), type (product, cart, checkout), environment (integration / production), amount-separator, alignment, dark-mode, logo-color Widget::html(); amount passed as amount with separator .
Widget test token Widget installation: “During the testing process, the merchant token value has to be filled with 8KDS3SPEL”; environment integration for tests Config::PUBLIC_TEST_TOKEN in sandbox mode
Widget placement Guide: product page “below the price”, cart page “below the cart total”, checkout “next to the other payment methods” Template hooks products:main_price (post), checkout:cart_content_bottom_right_buttons (pre), the storefront payment template
Test cards Test: success 5200828282828210, failure 4000000000000341 docs/installation.md
Countries, currency, minimum OC plugin: “The authorised countries are Austria, Belgium, Finland, France, Germany, Holland, Italy, Portugal and Spain”, “The only authorised currency is Euro”, “The minimum amount … is set to 5 EUR. If you would like to modify the maximum amount, contact your Sale referent” Config::COUNTRIES, Money::CURRENCIES = ['EUR'], default minimum 5.00
  • No merchant configuration API is documented. Older Scalapay integrations read limits from a configuration endpoint; the current reference index (llms.txt, 2026-11-08) lists none, and /reference/get_v3-configurations answers 404. The minimum and maximum are therefore merchant settings (default minimum 5.00 EUR from the OC plugin page).
  • Amount format. The queue brief said “EUR minor units”; the official schema uses decimal strings (“Example: 187.95”). The add-on follows the schema.
  • Webhook version string. The documentation shows the version only in its examples ('V1', header x-scalapay-hmac-v1). V1 is tried first, v1 second. A signature is still required, and the body is never trusted.
  • Signed bytes. The documented raw string is {version}:{timestamp}:{JSON.stringify(payload)}. The raw request body is verified first; the body re-encoded compactly (json_encode without escaped slashes) second, in case the bytes on the wire differ from Scalapay’s stringify output. A body that matches neither is refused (401) and the order is still picked up by the return, the waiting page and the cron.
  • Public test key and webhooks. In sandbox mode without an own key the HMAC key is the public test key, so anyone can sign a sandbox hook. Harmless: a hook only triggers a read-back.
  • Comma decimals. Some documented examples write amounts with a comma (“40,70” in the Delay answer, “190,00” in a Capture example) while the schema says “187.95”. Money::parse() accepts one comma when there is no dot.
  • Status after a partial refund. Webhooks: refunded is “Triggered when a refund request is successfully processed”, so it may come after a partial refund. It is acted on only when this shop made no refund itself (a refund in the Merchant Portal); the shop’s own refunds are counted from their answers.
  • Item prices. Items are sent with the unit price as charged and the taxes added on top in taxAmount, as the documented example adds up (items + shipping + tax - discounts = total). A rest from rounding, a payment surcharge or a gift certificate becomes one extra line, so the parts always add up to totalAmount.
  • 404 on Get. Only 422 invalid_token means “unknown order”; a 404 (not documented for this call) is treated as no clear answer and read again later.
  • Webhook timestamp age. No maximum age is documented, so none is enforced; a replayed hook only causes a read-back.
  • Statuses after void, delay and partial refund. The Get example shows charged and expired; the status of a voided order, of a delayed order and of a partly refunded order is not documented. The add-on sets its own state from the 200 answer of each call and never moves a final state backwards on an unknown status.
  • Refund amounts in Get. Get documents no refunded total. Refunds are tracked locally from the answers; an unclear refund is re-sent with the same Idempotency-Key within 24 hours, then flagged for a check in the Merchant Portal.
  • Several captures. “Up to the total” is documented, several partial captures are not; 1.0 allows one capture per order (full or lower amount).
  • Idempotency-Key lifetime is not documented; resends stop after 24 hours.
  • Return query parameters. What Scalapay appends to the redirect URLs is not documented; the add-on ignores them and reads the order back.
  • Not run against the real sandbox. The rules forbid tests against the real sandbox; everything was proven against tests/mock-server/server.js (the OpenCart port’s stand-in plus several accounts), which follows the sources above. Run one real sandbox order with your own setup before going live (public key + test card 5200828282828210).

Directive (EU) 2023/2225 (https://eur-lex.europa.eu/eli/dir/2023/2225/oj) brings “buy now, pay later” credit into the consumer credit rules (recital 69). Member States apply their national measures from 20 November 2026 (Art. 48(1)). Relevant for how a shop presents the method: advertising must be “fair, clear and not misleading” (Art. 7); advertising must carry a warning such as “Caution! Borrowing money costs money” (Art. 8(1)); Member States may ban advertising that “highlights the ease or speed with which credit can be obtained” (Art. 8(8)(a)); consent may not be inferred from default options such as pre-ticked boxes (Art. 15).

What the add-on does to help: the options are listed next to the other payment methods and none is pre-selected; its own texts name the method and make no claims about cost, ease or speed; the instalment amounts, dates and terms come from Scalapay’s own widget and checkout page; an optional “Information line” lets the merchant show a text their adviser asks for. Scalapay is the creditor; the merchant decides how the shop advertises. This is not legal advice.

  • Return without a session (SameSite). CS-Cart’s session cookie is SameSite=Lax in 4.17.2 and 4.21.2 (Tygh\Web\Session::$cookie_samesite). A cross-site GET navigation sends it, a cross-site POST does not; on a request without the cookie core starts a new session and its Set-Cookie would replace the customer’s session (cart and sign-in lost). Scalapay does not document how it sends the customer back, so redirectConfirmUrl / redirectCancelUrl point to index.php?dispatch=kv_scalapay.back&o=<order>&k=<key>&no_session=Y: core’s fn_init_ua() defines NO_SESSION for no_session=Y (4.17.2 and 4.21.2), so no session starts for that request and the customer’s session cookie is never replaced (on 4.21 with the full page cache on, core’s full_page_cache_unmanaged may start a separate fpc_customer session for a POST, which still never matches the real fpc_sid_customer_* cookie). The page checks the two parameters (digits, 32 hex) and continues with a same-site navigation (meta refresh + location.replace) to payment_notification.return, where the cookie is sent again. Not a cacheable dispatch; Cache-Control: no-store; storefront CSRF validation does not apply to this controller (schemas/security/csrf_validation.php).
  • Statuses (OpenCart audit majors). Transition::orderStatus() decides every order status change: (1) a capture moves the order from N, O, F, B, T AND from the configured “authorized” status to the paid status, so a delayed capture never leaves a captured order at a waiting status; (2) refunded read from Scalapay changes nothing when the shop made a refund itself (only a payment captured and never refunded by the shop becomes “refunded”); (3) a delayed authorization that expires (webhook, or the cron read-back after capture_until) or is voided moves the order from the authorized status to the failed status (Transition::LAPSED). The authorized status defaults to O (Open), not a paid status. A capture by status of an order whose newest attempt is expired / voided / declined writes a “NOT paid” note.
  • States. created -> pending -> approved -> captured | authorized (delayed) -> partial_refund | refunded; voided, expired, declined (capture refused), failed (never created), error (flag mismatch / duplicate / late, voided). open() = created, pending, approved.
  • Start (PaymentService::initialize(), order lock): earlier attempts first (paid / authorized = success; created without an answer or approved with a running capture = busy; pending = the customer left Scalapay: a new attempt is allowed and a late authorization of the old one is voided as a duplicate). Amount = order total converted once into EUR with the shop’s rate and stored with the order total (order_total), limits checked, payload built, row with the Idempotency-Key BEFORE the call; a retryable failure re-sends once with the same key, then keeps the attempt created for the cron, which finds the token by merchant reference (15 minutes, same amount required).
  • Read-back (applyLocked(), order lock + payment lock): only open attempts can become paid; an amount different from the created one, or a CS-Cart order total that changed (total vs the stored order_total, no new conversion), is flagged mismatch and voided after the locks; a second authorization of a paid order (duplicate) or one for an order whose every (child) order is in a stop status (late) is voided. A good authorization becomes approved and is captured (standard flow) or delayed after the locks; a delay Scalapay refuses falls back to an immediate capture; a refused automatic capture voids the authorization and fails the order. The webhook path stores these steps as queue jobs and runs them after the answer was sent.
  • Multi-Vendor. Owner mode: the cart is paid on the parent order (Transactions::payingOrderId() resolves child -> parent everywhere). One capture per payment: standard flow at once; delayed flow when every child order is at the capture status or in a stop status (stopped children’s shares are not captured; all stopped = void). After the capture share rows record each child’s captured share (Transactions::captureShares(), the last child takes the rounding rest); a child refunds at most its share (Transactions::shareLeft()), refund rows carry the child order id. Vendors in owner mode see the tab but never move money (Access::mayMoveMoney()); global actions (cron, run_now, backfill) are admin only. Vendor mode: one account per vendor, carts with several vendors hide the method, the webhook URL carries the vendor’s account id and is verified with that vendor’s key.
  • Pending return. When the read-back is still open (or the payment is a delayed authorization, whose order status O is not a paid status), the return empties the cart with fn_clear_cart() AND deletes the saved rows of ?:user_session_products (a signed-in customer’s cart is reloaded from there), resets the shipping rates and shows checkout.complete. The cancel URL with nothing authorized returns to the checkout with the cart; the attempt stays pending, never “cancelled”.
  • Full page cache (4.21). The product widget is printed in products:main_price from the product price, the language and the display currency (all part of the Varnish hash: URL, fpc_currency, fpc_sl) and shop-wide settings; nothing from the session. It shows only while CART_SECONDARY_CURRENCY is EUR. The cart and checkout widgets read the session cart, but those pages are never cached.
  • Operations (ops): creating -> done / failed / unknown; before a resend the order is read back (capture: charged = done; void: expired = done); the rest is re-sent with the same key within 24 hours, then closed with a note. A refund answered with another amount is booked as answered (AMOUNT_DIFFERS).
  • Shop time. Deadlines and notes use date() (CS-Cart sets the PHP zone from the store settings); gmdate() is not used.
  • Tests. php81 tests/unit.php and php83 tests/unit.php (no store). node tests/mock-server/server.js 11984 + node tests/e2e/e2e.mjs --store ... --db ... --dir ... (--only lifecycle --root <store dir> = retention lifecycle) and node tests/e2e/vendor.mjs (Multi-Vendor). Results: tests/test-plan.md.