Do not base logic on the message text. Branch on the HTTP status only. The messages change without notice, and some of them contain internal backend class names - for example PaySubProductEntity not found. There is no separate machine-readable error code.
Two departures from the shape above are worth knowing:Malformed JSON in the request - caught before the data reaches business logic:
This section matters as much as the table above. The gateway does not provide the mechanisms below - writing code that handles them is dead weight and misleading:
Mechanism
State
Rate limiting and status 429
Do not exist - the gateway does not throttle calls
Status 422
Never returned. Validation failures surface as 400 or 500
Idempotency-Key header
Does not exist. externalId plays the role of an idempotency key
API versioning
None. There is no /v1/ prefix and no version header
Deprecation / Sunset headers
Never sent
Machine-readable error code (code, type)
Does not exist. Only status and message are available
The most dangerous category: the API returns 200 even though the request was wrong. Check these before declaring the integration working.
A decimal amount is silently truncated."price": 12.99 is stored as 12 grosze and the response is 200. Convert with Math.round(amount * 100).
Unknown fields are ignored. Sending amount instead of price, or currency or returnUrl at the top level instead of inside details, raises no error - the payment is created with incomplete data.
A reused externalId returns the old payment. The response is 200 with the previously created redirectUrl, and the new amount is discarded. Change amounts via PATCH /api/pay/product/{productId}/subproduct/{externalId}.
A missing externalId returns 500, not 400. Before treating a 500 as transient and retrying, check that your body contains externalId.
A missing price or details.returnUrl raises no error at all. You get 200 and a working redirectUrl - to a payment with no amount, or with no way back to your shop. You have to check those fields yourself, before sending the request.
A malformed UUID in the path also returns 500. A typo in productId does not produce a readable 400 - you get the generic Something went wrong.
const response = await fetch(url, options);if (!response.ok) { const error = await response.json(); // { status, message } switch (response.status) { case 401: case 403: // Configuration problem - retrying will not help throw new PaymentConfigError(error.message); case 404: throw new PaymentNotFoundError(error.message); case 400: // Bad request - fix the data, do not retry throw new PaymentRequestError(error.message); case 500: // Verify the body is complete first, only then retry throw new PaymentServerError(error.message); default: throw new Error(`Paymove ${response.status}: ${error.message}`); }}
Retrying makes sense only for 500 and network failures, and only after confirming the request was complete. Statuses 400, 401, 403 and 404 indicate a problem on your side - a retry returns the same result.
The Node.js SDK wraps the responses above in typed exceptions:
Class
When
PaymoveValidationError
An argument was rejected by the SDK before the request was sent. The field property names the parameter
PaymoveApiError
The API returned a non-2xx status. statusCode and responseBody hold the original response
PaymoveNetworkError
The request never completed. cause holds the underlying exception
The SDK’s amount validation only checks that the value is a number greater than zero. An amount of 49.99 passes that check and the API truncates it to 49 grosze.