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 |
Sources
Section titled “Sources”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):
- API Architecture https://developers.scalapay.com/reference/api-architecture (“Arch”)
- Payment flows https://developers.scalapay.com/reference/payment-flows-1, Standard order flow https://developers.scalapay.com/docs/standard-capture-flow, Delayed capture order flow https://developers.scalapay.com/docs/delayed-capture-flow (“Flows”)
- API calls https://developers.scalapay.com/docs/custom-integration-api-calls
- Create order https://developers.scalapay.com/reference/post_v2-orders (“Create”)
- Get order details https://developers.scalapay.com/reference/get_v2-payments-token (“Get”)
- Get orders by reference https://developers.scalapay.com/reference/get_v2-payments-references (“Refs”)
- Capture order https://developers.scalapay.com/reference/post_v2-payments-capture (“Capture”)
- Delay order https://developers.scalapay.com/reference/post_v2-payments-token-delay (“Delay”)
- Void order https://developers.scalapay.com/reference/post_v2-payments-token-void (“Void”)
- Refund order https://developers.scalapay.com/reference/post_v2-payments-token-refund (“Refund”)
- Webhooks https://developers.scalapay.com/reference/webhooks, Listener authentication setup https://developers.scalapay.com/reference/listener-authentication-setup, Webhook data model https://developers.scalapay.com/reference/wehook-data-model, Messages validation https://developers.scalapay.com/reference/messages-validation, Examples of webhook requests https://developers.scalapay.com/reference/examples-of-webhook-requests, Connection issues and retry system https://developers.scalapay.com/reference/connection-issues-and-retry-system (“Hooks”)
- API Simulator https://developers.scalapay.com/reference/api-simulator; Test the order flow https://developers.scalapay.com/docs/test-the-order-flow (“Test”)
- Widget installation https://developers.scalapay.com/docs/custom-integration-widget-installation, Widget configuration https://developers.scalapay.com/docs/custom-integration-widget-configuration (“Widget”)
- Guidelines & best practices https://developers.scalapay.com/docs/best-practice-guideline (“Guide”)
- OpenCart v2 and v3 plugin https://developers.scalapay.com/docs/opencart-v2-v3-plugin (“OC plugin”: Scalapay’s own extension for OpenCart 1.x to 3.x)
- Go Live https://developers.scalapay.com/docs/go-live
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 |
Unverifiable or flagged
Section titled “Unverifiable or flagged”- 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-configurationsanswers 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', headerx-scalapay-hmac-v1).V1is tried first,v1second. 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_encodewithout 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:
refundedis “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 tototalAmount. - 404 on Get. Only 422
invalid_tokenmeans “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
chargedandexpired; 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).
EU Consumer Credit Directive (CCD2)
Section titled “EU Consumer Credit Directive (CCD2)”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.
Internals worth knowing
Section titled “Internals worth knowing”- Return without a session (SameSite). CS-Cart’s session cookie is
SameSite=Laxin 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 itsSet-Cookiewould replace the customer’s session (cart and sign-in lost). Scalapay does not document how it sends the customer back, soredirectConfirmUrl/redirectCancelUrlpoint toindex.php?dispatch=kv_scalapay.back&o=<order>&k=<key>&no_session=Y: core’sfn_init_ua()definesNO_SESSIONforno_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 separatefpc_customersession for a POST, which still never matches the realfpc_sid_customer_*cookie). The page checks the two parameters (digits, 32 hex) and continues with a same-site navigation (meta refresh +location.replace) topayment_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)refundedread 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 aftercapture_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 attemptcreatedfor 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 (totalvs the storedorder_total, no new conversion), is flaggedmismatchand 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 becomesapprovedand 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 capturesharerows 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 showscheckout.complete. The cancel URL with nothing authorized returns to the checkout with the cart; the attempt stayspending, never “cancelled”. - Full page cache (4.21). The product widget is printed in
products:main_pricefrom 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 whileCART_SECONDARY_CURRENCYis 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.phpandphp83 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) andnode tests/e2e/vendor.mjs(Multi-Vendor). Results:tests/test-plan.md.