CentaPay

Last updated

Troubleshooting#

Symptoms you will actually hit, and what causes them.

Availability. CentaPay is pre-launch. Production is expected in Q4 2026, with sandbox access ahead of it. Coverage differs by market, and the payment methods available differ by market too. The coverage table on our main site is the single source for what is live where, and for current dates. Nothing in this documentation should be read as a service available today.

Every entry here is a failure mode documented in the reference. Nothing on this page is speculative: if a cause is not established, the symptom says so rather than offering a guess.

Payloads below are illustrative and use the same sample credentials as the guides, so every hash on this page is reproducible: a payer_email of [email protected], a PASSWORD of SANDBOX_PASSWORD, and the sandbox test card. Field names, their presence or absence, and every hash are exact.

Requests get no response at all#

Not an error, not a timeout you can distinguish from a network fault. Nothing.

Requests from un-whitelisted IP addresses are rejected silently. That is the documented behaviour, and it is indistinguishable from your own connectivity failing.

Before assuming a network problem, confirm every address your server can send from is on the whitelist, including the ones you forgot: a second data centre, a NAT gateway, a scheduled job running somewhere else.

Separately, you cannot process payments at all until the S2S CARD protocol has been mapped to your account during onboarding. Both are configuration held by CentaPay, not something you can change from your side.

Every request fails authentication#

Almost always the wrong formula for the action, not a wrong password.

The formulas are not interchangeable, and picking the wrong one produces an authentication failure that says nothing about which part was wrong.

SendingSigns with
SALE with card dataFormula 1
SALE with card_tokenFormula 1 variant, token replaces the card fragment
SALE with a walletFormula 8, email and password only
CAPTURE, CREDITVOID, VOIDFormula 2
RECURRING_SALE, RETRYFormula 1
CREDIT2CARDFormula 5
CREATE_SCHEDULEFormula 3
Other schedule operationsFormula 4

Three specific traps inside that table.

A wallet sale signed with Formula 1 fails, because Formula 8 has no card term and a wallet sends no card fields.

RECURRING_SALE signs with Formula 1, which needs the card's first six and last four digits, but the request itself sends no card fields. Those digits must come from the mask you stored at the initial sale.

CREDIT2CARD signs with Formula 5, which has no email term at all.

Callback verification fails but requests work#

Three separate causes, all common.

The formula differs between request and callback. This is the norm, not the exception. A wallet sale signs Formula 8 and verifies Formula 2. A VOID signs Formula 2 and verifies with the Void signature. A CREDIT2CARD signs Formula 5 and verifies Formula 6. One shared verification routine will pass on most callbacks and fail on those.

trans_id is not uppercased. Every formula uppercases the whole concatenation. A lowercase UUID passed through unchanged produces a silent mismatch.

The callback has no card field. Declined callbacks, 3DS and redirect callbacks, and undefined callbacks all omit card and card_expiration_date. Formula 2 needs a card fragment, so verification has to fall back to the mask you stored when you created the payment. If you did not store it, you cannot verify those callbacks at all.

A redirect callback, with the two absent fields visible by their absence:

action=SALE
result=REDIRECT
status=3DS
order_id=TDS-2001
trans_id=e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9
trans_date=2026-07-25 14:32:07
descriptor=CENTAPAY TDS
amount=120000
currency=UZS
hash=4008791549623c0d41f3cb0bd9816d79

That hash is the one the final callback for the same payment carries. Formula 2 reads the email, the password, trans_id and the card fragment, and none of those change between the two, so a verification routine that works on one works on the other once it has a card fragment to use.

The masked PAN behaves exactly like a full one. First six characters joined to the last four, and the asterisks in the middle are never used.

Callbacks stop arriving#

Five consecutive timeouts within five minutes blocks your callback URL for fifteen minutes. Any successful response resets the counter. Unblocking early is done through the admin panel, under Configuration, Merchants, Edit Merchant.

Two consequences worth designing around.

Your endpoint must return the plain string OK. Any other content counts as a failure, and so does a timeout. Acknowledge first and process afterwards, so slow downstream work cannot trip the counter.

All merchants sharing that URL are affected. One slow consumer takes the others down with it, which is the argument against a single shared callback endpoint across environments or entities.

How many times a callback is delivered#

Four attempts: one immediately, then at fifteen, thirty and forty-five minutes. A callback that is never acknowledged across all four is not delivered again.

Read that as the platform's schedule for reaching you. It is not advice about retrying your own failed API requests, which is a different situation with a different answer, and one these pages do not give.

There is no delivery log. Nothing exposes the individual attempts, their timings or their responses. A callback can be resent by hand from Transaction Details in the admin panel, and that is the only control over delivery you have.

Design reconciliation around your own record of what you received, not around a platform log, because there is not one to query.

A payment succeeded but the customer was not charged, or vice versa#

You are reading the synchronous response as the outcome. It is not.

The synchronous response tells you the request was accepted and what happened at that instant. result can be UNDEFINED, which is not an error and frequently settles. It can be REDIRECT, which means authentication is still ahead. The callback is authoritative in every case.

Duplicate orders after a timeout#

order_id must be unique per payment. Submitting one already used returns error code 400.

If a request times out or the connection drops, do not resend it. Call GET_TRANS_STATUS_BY_ORDER with your order_id and read the actual state.

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=GET_TRANS_STATUS_BY_ORDER" \
  -d "client_key={CLIENT_KEY}" \
  -d "order_id=TAP-1001" \
  -d "hash=a4593af2e3d7c92da965d39a5a2c27e7"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'GET_TRANS_STATUS_BY_ORDER',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'TAP-1001',
    'hash' => 'a4593af2e3d7c92da965d39a5a2c27e7',
];

$body = http_build_query($fields);

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);

$result = json_decode($response, true);
import requests
from urllib.parse import urlencode

url = 'https://{PAYMENT_URL}/post'

fields = {
    'action': 'GET_TRANS_STATUS_BY_ORDER',
    'client_key': '{CLIENT_KEY}',
    'order_id': 'TAP-1001',
    'hash': 'a4593af2e3d7c92da965d39a5a2c27e7',
}

body = urlencode(fields)

response = requests.post(
    url,
    data=body,
    headers={'Content-Type': 'application/x-www-form-urlencoded'},
)

result = response.json()
const url = 'https://{PAYMENT_URL}/post'

const fields = {
  'action': 'GET_TRANS_STATUS_BY_ORDER',
  'client_key': '{CLIENT_KEY}',
  'order_id': 'TAP-1001',
  'hash': 'a4593af2e3d7c92da965d39a5a2c27e7',
}

const body = new URLSearchParams(fields).toString()

const response = await fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body,
})

const result = await response.json()

The query sends no card fields but Formula 7 signs with them, so the first six and last four digits come from the mask you stored, exactly as they do for RECURRING_SALE. The response carries status, plus decline_reason if the payment declined.

With cascading enabled this returns the most recent transaction only.

Blind resubmission creates duplicates, and where cascading is enabled a single payment request can already have generated several underlying transactions, so a retry can multiply rather than repeat.

A capture or refund is rejected on amount#

Both are arithmetic, and both are terminal for the request as sent.

Capture amount exceeding the authorised amount returns 208004. Refund amount exceeding the payment returns 208006, and reversal amount exceeding it returns 208008.

{
  "result": "ERROR",
  "error_message": "Description of the error",
  "error_code": 208004
}

result: ERROR with an error_code is a validation failure, and it is a different shape from a declined transaction, which returns result: DECLINED with a decline_reason. A handler that branches on result alone sees these as the same thing.

Note the asymmetry that catches people: one partial capture is allowed, and the remainder of the authorisation is then gone. Multiple partial refunds are allowed. Code written for one does not transfer to the other.

A partial refund callback says SETTLED#

That is correct and is not a failure.

A partial refund reports status: SETTLED because the original transaction keeps its settled status. A full refund reports REFUND or REVERSAL.

A VOID was declined#

A declined VOID returns result: DECLINED with status: SETTLED, leaving the original transaction settled and a customer expecting their money back.

VOID only applies to transactions in SETTLED status from SALE, CAPTURE or RECURRING_SALE, and only on the same financial day. Outside that window the operation is CREDITVOID.

A payout response is missing fields#

CREDIT2CARD success responses do not include amount or currency, unlike a SALE. A handler that reads them unconditionally breaks on the success path.

A success response in full. Every field it has, and nothing where amount and currency would be:

{
  "action": "CREDIT2CARD",
  "result": "SUCCESS",
  "status": "SETTLED",
  "order_id": "PAY-5001",
  "trans_id": "c9d0e1f2-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
  "trans_date": "2026-07-25 18:12:44",
  "descriptor": "CENTAPAY PAYOUT"
}

Declined responses carry decline_reason in place of descriptor, and undefined ones carry status: PREPARE.

CREDIT2CARD is also the one action whose callback does not verify with Formula 2. It uses Formula 6, and Pay out to a card carries the worked callback and both digests.

A wallet payment declines after the customer has paid#

allowedCardNetworks populates the Google Pay payment sheet. Any network listed is offered to the cardholder. If they pick one your account cannot acquire, Google returns a valid token, the sale declines, and the customer has already been told they paid.

List only the networks enabled on your account, confirmed at onboarding.

Also check that the Environment setting matches, TEST or PRODUCTION, between your Google Pay configuration and your CentaPay account.

Recurring will not start in sandbox#

Only the test card with expiry 01/2038 returns a recurring_token. The other expiries will not produce one.

If a recurring charge then fails, note that RETRY applies to soft declines only. Hard declines such as a stolen card or fraud will not succeed on retry.

The 3DS redirect does nothing#

redirect_params varies by acquirer. It may contain PaReq, TermUrl or other values, it may be empty, and it may be absent altogether, most commonly when redirect_method is GET.

Code that assumes an array here fails on the acquirer that sends nothing. Check for presence before iterating.

What next#

Each guide documents the failure modes specific to its operation.

Technical questions go to [email protected].