Developer notes
Technical reference for developers who customize PayU for CS-Cart and Multi-Vendor.
Add-on identity
Section titled “Add-on identity”| Item | Value |
|---|---|
| Add-on id | kv_payu |
| Function prefix | fn_kv_payu_ |
| Language variables | kv_payu.* |
| CSS scope | .kv-payu |
| PHP namespace | Tygh\Addons\KvPayu |
| Preset | payment (generated by kv new --kind integration) |
Structure
Section titled “Structure”| Class | Role |
|---|---|
Client\BaseClient |
HTTP (cURL, TLS verification, timeouts), JSON, OAuth2 client-credentials with an encrypted token cache, error mapping, masked logging |
Client\Api |
PayU REST API 2.1: OAuth, create order, retrieve order, refunds, payment methods (see “Official API reference” below) |
Client |
Client per company, the connection test |
Accounts, Secret |
Credentials (test and live) sealed with AES-256-GCM; one account per store owner or per vendor |
Config, Access |
Settings, owner mode, who may see and manage what |
Queue, Jobs, JobException |
Job queue: back-off 5 min / 15 min / 1 h / 3 h / 12 h, per-job isolation, overlap lock; handlers in Jobs::handle(), recurring work in Jobs::schedule() |
Log |
Event log with masking, filters, CSV, retention |
Webhook |
OpenPayu-Signature check and event ids (order id + status); the controller kv_payu.webhook?c=<account> stores the event as a job |
Database tables
Section titled “Database tables”| Table | Purpose |
|---|---|
?:kv_payu_accounts |
Credentials per company (secrets sealed), last connection check, token cache |
?:kv_payu_log |
Log |
?:kv_payu_jobs |
Queue |
?:kv_payu_transactions, ?:kv_payu_refunds |
Payments and refunds |
Hooks used
Section titled “Hooks used”PHP: change_order_status_post (automatic refund when an order reaches the chosen status), prepare_checkout_payment_methods (hides the method while it cannot start payments). Template hooks: orders:tabs_extra (payment tab), addons:manage / addons:tabs_content (licence), index:styles.
Official API reference (every call and field, with its source)
Section titled “Official API reference (every call and field, with its source)”Source of truth: PayU Europe developer documentation, https://developers.payu.com/europe/ (API reference https://developers.payu.com/europe/api/, “Payment lifecycle” /europe/docs/payment-flows/lifecycle/, “Sandbox” /europe/docs/testing/sandbox/, “References” /europe/docs/get-started/integration-overview/references/, “Accept payment” /europe/docs/get-started/integration-overview/accept-payment/). The notification signature algorithm list is checked against the official PHP SDK, https://github.com/PayU-EMEA/openpayu_php (lib/OpenPayU/Util.php). Checked 2026-11-07.
| What | Value | Source |
|---|---|---|
| Hosts | production https://secure.payu.com, sandbox https://secure.snd.payu.com |
API reference, Servers |
| OAuth | POST /pl/standard/user/oauth/authorize, form fields grant_type=client_credentials, client_id, client_secret; answer access_token, token_type, expires_in (43199 s), grant_type; later calls send Authorization: Bearer <token> |
API reference; Accept payment step 1 |
| Create order | POST /api/v2_1/orders, Content-Type: application/json. Required: customerIp, merchantPosId, description (max 4000), currencyCode (ISO 4217), totalAmount (string, minor units), products[] (name, unitPrice, quantity). Used optional: extOrderId (max 1024, unique per POS), notifyUrl (max 1024), continueUrl, buyer (email, firstName, lastName, language) |
API reference, Create an Order |
| Create order answer | HTTP 201, or 302 when redirects are not followed (the client never follows them): status.statusCode = SUCCESS, orderId, extOrderId, redirectUri (the PayU payment page) |
API reference; Accept payment step 2 and 3 |
customerIp |
IPv4 dotted decimal or IPv6 as eight groups of four hex digits (the add-on expands the short form) | API reference |
| Amounts | integer minor units as a string: 1000 = 10.00 EUR, also for currencies without subunits | API reference |
| Retrieve order | GET /api/v2_1/orders/{orderId}; answer orders[] (orderId, extOrderId, merchantPosId, currencyCode, totalAmount, status as a string) and properties[] (PAYMENT_ID) |
API reference, Retrieve an Order |
| Order statuses | PENDING, WAITING_FOR_CONFIRMATION (auto-receive off), COMPLETED, CANCELED |
Payment lifecycle |
| Notification | POST to the notifyUrl of the order, body {order{orderId, extOrderId, merchantPosId, totalAmount, currencyCode, status, ...}, localReceiptDateTime, properties[]}; PayU expects HTTP 200, otherwise repeats (up to 20 times: now, 1, 2, 5, 10, 30 min, 1, 2, 3, 6, 9, 12, 15, 18, 21, 24, 36, 48, 60, 72 h); duplicates with the same status can occur |
Payment lifecycle |
| Signature | header OpenPayu-Signature: sender=checkout;signature=<hex>;algorithm=MD5;content=DOCUMENT; expected = hash(algorithm, raw body + second key), compared in constant time. Accepted algorithms: MD5, SHA-256, SHA-384, SHA-512 (the SDK also reads SHA-1, which the add-on refuses) |
Payment lifecycle; openpayu_php Util::verifySignature |
| Refund | POST /api/v2_1/orders/{orderId}/refunds, body {"refund": {"description" (required), "amount" (string, minor units; omitted = full), "extRefundId", "currencyCode"}}; answer {orderId, refund{refundId, description, amount, extRefundId, currencyCode, creationDateTime, status: PENDING, statusDatetime}, status{statusCode, statusDesc}}; identical data returns the existing refund (idempotent) |
API reference, Refunds |
| Refund read | GET /api/v2_1/orders/{orderId}/refunds/{refundId}; refund statuses PENDING, FINALIZED, CANCELED |
API reference |
| Payment methods | GET /api/v2_1/paymethods (used by “Test connection”) |
API reference |
| Error body | {"status": {"statusCode", "severity", "code", "codeLiteral", "statusDesc"}}; OAuth errors {"error", "error_description"} |
API reference |
| Currencies | CHF, CZK, DKK, EUR, GBP, HUF, NOK, PLN, RON, SEK, USD (settled 1:1); cards in any currency, other methods only CZK, EUR, PLN | References |
| TLS | TLS 1.2 or newer, SNI required | Accept payment, Transmission requirements |
Sandbox
Section titled “Sandbox”Public sandbox POS credentials of the PayU docs (Sandbox page): PLN POS 300746 (second key b6ca15b0d1020e8094d9b5f8d163db54, OAuth client secret 2ee86a66e5d97e3fadc400c9f19b065d), BGN POS 404815, RON POS 491057. OAuth client ID equals the POS ID. Test cards, e.g. 4444333322221111 (12/29, CVV 123) positive authorization. These are public test values of PayU, not secrets. The sandbox is for functional tests only (HTTP 429 on excessive traffic).
Flagged: not verified in the documentation pages we could read
Section titled “Flagged: not verified in the documentation pages we could read”These points are handled defensively and are covered by the add-on’s read-back, never trusted from a body:
Resolved against the official OpenAPI file payu-api-ref.yaml (PayU GPO Europe REST API 2.1.0, https://developers.payu.com/europe/resources/payu-api-ref.yaml, checked 2026-11-07):
- Refund notification body:
{orderId, extOrderId, refund{refundId, extRefundId, amount, currencyCode, status: FINALIZED | CANCELLED, ...}}. The notification spells the canceled status CANCELLED, refund create/read spell it CANCELED; the add-on accepts both. A refund is found byrefundIdor, when our request got no answer, byextRefundId. The read-back (GET .../refunds/{refundId}) is a top-level object; the add-on accepts the wrapped form too. - REJECTED is not an order status of the spec (
NEW, PENDING, WAITING_FOR_CONFIRMATION, COMPLETED, CANCELED); it only exists as a card authorization status. The mapping to failed is harmless and kept. - OAuth error body is
{error, error_description}(400, 401); the add-on reads exactly these. - WAITING_FOR_CONFIRMATION (auto-receive off) is treated as pending. The add-on does not capture (
PUT /api/v2_1/orders/{orderId}/statusexists); it logs a warning and shows it on the order tab. Auto-receive is a documented requirement. - BGN is not a settlement currency in the References page (card table only) and Bulgaria uses the euro since 2026-01-01, so it is no longer offered; an order in an unsupported currency is converted to PLN.
- Real PayU sandbox: not run (needs a registered sandbox merchant for a webhook-reachable URL); the public POS credentials were not used to place a live order. Tests use the mock built from the pages above.
Money rules
Section titled “Money rules”- Amounts are integer minor units sent as strings;
productsis one line withunitPrice = totalAmount, so the sums always agree. extOrderId=kv<txn id>-<8 hex of the access key>: unique per attempt, derivable, so nothing extra is stored.- A payment becomes
paidonly afterGET /orders/{orderId}returnedCOMPLETEDandorderId,extOrderId,merchantPosId,totalAmountandcurrencyCodeequal what the add-on created. A mismatch is logged, stored on the transaction and the job is not retried. - A notification body is only a hint (the job reads the order back). The same order + status is stored once (idempotent); the customer’s return and the cron (
Jobs::schedule) recover a lost notification. - A refund row (with its
extRefundIdand description) is stored before the request. If the request ends without an answer (timeout, 5xx) the row staysunknown, nothing is counted, and every retry (click or queue) sends the sameextRefundId, amount and description: the official spec says identical data returns the existing refund instead of creating one. While a row isunknown, only the same amount can be refunded. The refunded total changes in one atomicUPDATE ... refunded_minor = refunded_minor + n, aCANCELED/CANCELLEDrefund takes its amount back the same way, and refunds of one payment run under a named lock (Lock). - The customer’s return, the notification, the cron queue and the admin Sync all end in
PaymentService::sync(), which runs under a lock per payment and re-reads the payment and the order inside it: stock and e-mails happen once. - Return while PayU is still pending: with the return the order moves N -> pending status (setting, default Open) and the customer is sent to
kv_payu.waiting(pollskv_payu.statusevery 5 s, thenkv_payu.finish).initialize()never starts a second PayU order for an order whose earlier payment is pending (the customer is sent to the open one) or paid. - Refunds move the owner’s money in owner mode:
Access::refundAllowed()denies vendors there (button, controller and the automatic refund at an order status).
Test the HTTP layer without the provider
Section titled “Test the HTTP layer without the provider”The base URL can be overridden for tests in config.local.php (development only):
$config['kv_payu_base_url'] = 'http://127.0.0.1:11981';node tests/mock-server/server.js 11981 starts a local stand-in of the provider API; node tests/e2e/e2e.mjs runs the HTTP end-to-end test against it; php tests/unit.php runs the unit tests.
Template overrides
Section titled “Template overrides”Admin templates live in design/backend/templates/addons/kv_payu/. Do not edit them in a store; they are replaced on update.