CentaPay

Last updated

Take a payment#

Accept a card payment from a customer in Uzbekistan.

Availability. CentaPay is pre-launch. Card acceptance in Uzbekistan runs on HUMO and UZCARD, with production expected in Q4 2026 and sandbox access ahead of it. Coverage differs by market, and Kazakhstan and Pakistan launch on other payment methods rather than cards. The coverage table on our main site is the single source for what is live where, and for current dates. Nothing in this guide should be read as a service available today.

This covers the one-step sale, the two-step authorise and capture, saving a card for later, and routing to a sub-merchant. The 3D Secure redirect has its own guide, because most of your live traffic will go through it and it deserves the space.

Every example below uses the same sample credentials so the hashes are reproducible: payer_email of [email protected], a PASSWORD of SANDBOX_PASSWORD, and the sandbox test card. Payload values are illustrative, but every field name, its presence or absence, and every hash is exact. You can recompute any digest on this page from the inputs shown.

The shape of a payment#

Three things happen, and only the third is authoritative.

You POST a signed request. You receive a synchronous JSON response telling you what happened at that instant. Some time later you receive a callback telling you what actually happened. The synchronous response can say SUCCESS and the payment can still fail, and it can say UNDEFINED and the payment can still succeed. Fulfil on the callback.

A single-step sale#

SALE authorises and captures in one operation. Use it when you ship or deliver immediately.

Request

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=SALE" \
  -d "client_key={CLIENT_KEY}" \
  -d "order_id=TAP-1001" \
  -d "order_amount=120000" \
  -d "order_currency=UZS" \
  -d "order_description=Order TAP-1001" \
  -d "card_number=4111111111111111" \
  -d "card_exp_month=01" \
  -d "card_exp_year=2038" \
  -d "card_cvv2=123" \
  -d "payer_first_name=John" \
  -d "payer_last_name=Smith" \
  -d "[email protected]" \
  -d "payer_phone=998901234567" \
  -d "payer_country=UZ" \
  -d "payer_city=Tashkent" \
  -d "payer_address=5 Amir Temur Ave" \
  -d "payer_zip=100000" \
  -d "payer_ip=203.0.113.10" \
  -d "term_url_3ds=https://yoursite.example/3ds-return" \
  -d "hash=c8b58f1a6a6083fd4f0bd17d3ef58a45"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'SALE',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'TAP-1001',
    'order_amount' => '120000',
    'order_currency' => 'UZS',
    'order_description' => 'Order TAP-1001',
    'card_number' => '4111111111111111',
    'card_exp_month' => '01',
    'card_exp_year' => '2038',
    'card_cvv2' => '123',
    'payer_first_name' => 'John',
    'payer_last_name' => 'Smith',
    'payer_email' => '[email protected]',
    'payer_phone' => '998901234567',
    'payer_country' => 'UZ',
    'payer_city' => 'Tashkent',
    'payer_address' => '5 Amir Temur Ave',
    'payer_zip' => '100000',
    'payer_ip' => '203.0.113.10',
    'term_url_3ds' => 'https://yoursite.example/3ds-return',
    'hash' => 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
];

$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': 'SALE',
    'client_key': '{CLIENT_KEY}',
    'order_id': 'TAP-1001',
    'order_amount': '120000',
    'order_currency': 'UZS',
    'order_description': 'Order TAP-1001',
    'card_number': '4111111111111111',
    'card_exp_month': '01',
    'card_exp_year': '2038',
    'card_cvv2': '123',
    'payer_first_name': 'John',
    'payer_last_name': 'Smith',
    'payer_email': '[email protected]',
    'payer_phone': '998901234567',
    'payer_country': 'UZ',
    'payer_city': 'Tashkent',
    'payer_address': '5 Amir Temur Ave',
    'payer_zip': '100000',
    'payer_ip': '203.0.113.10',
    'term_url_3ds': 'https://yoursite.example/3ds-return',
    'hash': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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': 'SALE',
  'client_key': '{CLIENT_KEY}',
  'order_id': 'TAP-1001',
  'order_amount': '120000',
  'order_currency': 'UZS',
  'order_description': 'Order TAP-1001',
  'card_number': '4111111111111111',
  'card_exp_month': '01',
  'card_exp_year': '2038',
  'card_cvv2': '123',
  'payer_first_name': 'John',
  'payer_last_name': 'Smith',
  'payer_email': '[email protected]',
  'payer_phone': '998901234567',
  'payer_country': 'UZ',
  'payer_city': 'Tashkent',
  'payer_address': '5 Amir Temur Ave',
  'payer_zip': '100000',
  'payer_ip': '203.0.113.10',
  'term_url_3ds': 'https://yoursite.example/3ds-return',
  'hash': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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()

Signed with Formula 1, over payer_email, PASSWORD and the card's first six and last four digits joined then reversed. The hash does not depend on the amount, the order or the currency, so the same card and customer produce the same signature on every request. That is expected.

payer_ip is the cardholder's address, not your server's. term_url_3ds is mandatory even here, where no redirect occurs, because the platform does not know in advance whether the issuer will challenge.

The four synchronous responses

A SALE returns one of four shapes. Handle all of them. Treating anything other than SUCCESS as a failure will cost you real payments, because UNDEFINED frequently settles.

Success. Authorised and captured.

{
  "action": "SALE",
  "result": "SUCCESS",
  "status": "SETTLED",
  "order_id": "TAP-1001",
  "trans_id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "trans_date": "2026-07-25 14:32:07",
  "descriptor": "CENTAPAY TAP",
  "amount": "120000",
  "currency": "UZS"
}

status can also be PENDING or PREPARE on a success, and PENDING appears here only when you sent auth=Y.

Redirect. The issuer wants to authenticate the cardholder. redirect_url, redirect_method and redirect_params are present, and redirect_params may be empty or absent depending on the acquirer. Covered in the 3D Secure guide.

Declined. result and status are both DECLINED, and decline_reason carries a human-readable explanation. Note what is missing: a declined response has no card field, which matters when you come to verify the callback.

Undefined. The outcome is not yet known.

{
  "action": "SALE",
  "result": "UNDEFINED",
  "status": "PREPARE",
  "order_id": "TAP-1001",
  "trans_id": "c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "trans_date": "2026-07-25 14:32:07",
  "descriptor": "CENTAPAY TAP",
  "amount": "120000",
  "currency": "UZS"
}

This is not an error. Show the customer a pending state and wait for the callback.

The callback

action=SALE
result=SUCCESS
status=SETTLED
order_id=TAP-1001
trans_id=c3d4e5f6-7a8b-4c9d-8e0f-1a2b3c4d5e6f
trans_date=2026-07-25 14:32:09
descriptor=CENTAPAY TAP
amount=120000
currency=UZS
card=411111****1111
card_expiration_date=01/2038
hash=d8a83f26a4462460a50aefff0927469b

Verify with Formula 2, over payer_email, PASSWORD, trans_id and the card's first six and last four. The masked PAN behaves exactly like the full one, since only the first six and last four characters are used and the asterisks are never touched.

Two things trip people up. The payer's email is not in the callback, so store it against your order_id when you create the payment. And trans_id is uppercased along with everything else before hashing, which a lowercase UUID makes easy to forget.

Return the plain string OK once you have accepted the notification, or ERROR if you have not. Acknowledge first and process afterwards, because five timeouts within five minutes block your callback URL for fifteen minutes and every merchant sharing that URL stops receiving notifications with you.

If your account has extended data enabled, the success callback also carries rrn, approval_code, issuer_country, issuer_bank, arn and related acquirer fields. They are useful for reconciliation and for disputes. They are configured per account, so do not depend on them being present unless you have confirmed they are switched on.

Amounts and currencies#

CurrencyCodeFormatExample
Kazakhstani TengeKZTInteger, no decimal component5000
Uzbekistani SomUZSInteger, no decimal component120000
US DollarUSDFloat, XX.XX49.99

Sending 50.00 in UZS is a formatting error, not a fifty-som payment. Both som and tenge are zero-exponent currencies, so the integer is the whole amount.

Where currency conversion is applied, the callback carries exchange_rate, exchange_currency and exchange_amount, and exchange_rate_base as well if the conversion was doubled. Store them. Reconciling a converted payment without the rate that was actually used is guesswork.

Authorise now, capture later#

Send auth=Y on the SALE to reserve funds without taking them. Everything else about the request is identical, including the signature.

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=SALE" \
  -d "client_key={CLIENT_KEY}" \
  -d "order_id=TAP-1002" \
  -d "order_amount=120000" \
  -d "order_currency=UZS" \
  -d "order_description=Order TAP-1002" \
  -d "auth=Y" \
  -d "card_number=4111111111111111" \
  -d "card_exp_month=01" \
  -d "card_exp_year=2038" \
  -d "card_cvv2=123" \
  -d "payer_first_name=John" \
  -d "payer_last_name=Smith" \
  -d "[email protected]" \
  -d "payer_phone=998901234567" \
  -d "payer_country=UZ" \
  -d "payer_city=Tashkent" \
  -d "payer_address=5 Amir Temur Ave" \
  -d "payer_zip=100000" \
  -d "payer_ip=203.0.113.10" \
  -d "term_url_3ds=https://yoursite.example/3ds-return" \
  -d "hash=c8b58f1a6a6083fd4f0bd17d3ef58a45"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'SALE',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'TAP-1002',
    'order_amount' => '120000',
    'order_currency' => 'UZS',
    'order_description' => 'Order TAP-1002',
    'auth' => 'Y',
    'card_number' => '4111111111111111',
    'card_exp_month' => '01',
    'card_exp_year' => '2038',
    'card_cvv2' => '123',
    'payer_first_name' => 'John',
    'payer_last_name' => 'Smith',
    'payer_email' => '[email protected]',
    'payer_phone' => '998901234567',
    'payer_country' => 'UZ',
    'payer_city' => 'Tashkent',
    'payer_address' => '5 Amir Temur Ave',
    'payer_zip' => '100000',
    'payer_ip' => '203.0.113.10',
    'term_url_3ds' => 'https://yoursite.example/3ds-return',
    'hash' => 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
];

$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': 'SALE',
    'client_key': '{CLIENT_KEY}',
    'order_id': 'TAP-1002',
    'order_amount': '120000',
    'order_currency': 'UZS',
    'order_description': 'Order TAP-1002',
    'auth': 'Y',
    'card_number': '4111111111111111',
    'card_exp_month': '01',
    'card_exp_year': '2038',
    'card_cvv2': '123',
    'payer_first_name': 'John',
    'payer_last_name': 'Smith',
    'payer_email': '[email protected]',
    'payer_phone': '998901234567',
    'payer_country': 'UZ',
    'payer_city': 'Tashkent',
    'payer_address': '5 Amir Temur Ave',
    'payer_zip': '100000',
    'payer_ip': '203.0.113.10',
    'term_url_3ds': 'https://yoursite.example/3ds-return',
    'hash': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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': 'SALE',
  'client_key': '{CLIENT_KEY}',
  'order_id': 'TAP-1002',
  'order_amount': '120000',
  'order_currency': 'UZS',
  'order_description': 'Order TAP-1002',
  'auth': 'Y',
  'card_number': '4111111111111111',
  'card_exp_month': '01',
  'card_exp_year': '2038',
  'card_cvv2': '123',
  'payer_first_name': 'John',
  'payer_last_name': 'Smith',
  'payer_email': '[email protected]',
  'payer_phone': '998901234567',
  'payer_country': 'UZ',
  'payer_city': 'Tashkent',
  'payer_address': '5 Amir Temur Ave',
  'payer_zip': '100000',
  'payer_ip': '203.0.113.10',
  'term_url_3ds': 'https://yoursite.example/3ds-return',
  'hash': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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()

A successful authorisation returns status: PENDING rather than SETTLED. Keep the trans_id.

Then capture. The capture request needs only the transaction, not the card.

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=CAPTURE" \
  -d "client_key={CLIENT_KEY}" \
  -d "trans_id=d4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70" \
  -d "hash=d3f9629e3a779b3052830e9dfacf3241"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'CAPTURE',
    'client_key' => '{CLIENT_KEY}',
    'trans_id' => 'd4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70',
    'hash' => 'd3f9629e3a779b3052830e9dfacf3241',
];

$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': 'CAPTURE',
    'client_key': '{CLIENT_KEY}',
    'trans_id': 'd4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70',
    'hash': 'd3f9629e3a779b3052830e9dfacf3241',
}

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': 'CAPTURE',
  'client_key': '{CLIENT_KEY}',
  'trans_id': 'd4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70',
  'hash': 'd3f9629e3a779b3052830e9dfacf3241',
}

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()

Omit amount to capture the full authorised sum. Include it to capture less. One partial capture is allowed, so if you capture 70,000 of a 120,000 authorisation the remaining 50,000 is gone, not available for a second capture.

The capture is signed with Formula 2, which needs the card's first six and last four even though you are not sending the card. Use the values from your own record of the original request.

There is a property here worth knowing before it confuses you. The Formula 2 digest that signs the capture request is byte-identical to the Formula 2 digest you use to verify the capture callback, because both hash the same email, password, trans_id and card fragment. Seeing the same hash go out and come back is correct, not a replay.

action=CAPTURE
result=SUCCESS
status=SETTLED
order_id=TAP-1002
trans_id=d4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70
trans_date=2026-07-25 15:04:11
descriptor=CENTAPAY TAP
amount=120000
currency=UZS
hash=d3f9629e3a779b3052830e9dfacf3241

A declined capture returns status: PENDING, not DECLINED, with a decline_reason. That is unusual and worth handling explicitly rather than falling through to a generic error branch.

Saving a card#

Add req_token=Y to a SALE. The response and the callback return a 64-character card_token.

  -d "req_token=Y"

For later charges, send card_token in place of card_number, card_exp_month, card_exp_year and card_cvv2.

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=SALE" \
  -d "client_key={CLIENT_KEY}" \
  -d "order_id=TAP-1003" \
  -d "order_amount=120000" \
  -d "order_currency=UZS" \
  -d "order_description=Order TAP-1003" \
  -d "card_token=7f3a9c1e5b2d84670a1c3e5f7b9d02468ace13579bdf02468ace13579bdf0246" \
  -d "payer_first_name=John" \
  -d "payer_last_name=Smith" \
  -d "[email protected]" \
  -d "payer_phone=998901234567" \
  -d "payer_country=UZ" \
  -d "payer_city=Tashkent" \
  -d "payer_address=5 Amir Temur Ave" \
  -d "payer_zip=100000" \
  -d "payer_ip=203.0.113.10" \
  -d "term_url_3ds=https://yoursite.example/3ds-return" \
  -d "hash=b803e9b0f8c69a391be0fe34b77eb0ca"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'SALE',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'TAP-1003',
    'order_amount' => '120000',
    'order_currency' => 'UZS',
    'order_description' => 'Order TAP-1003',
    'card_token' => '7f3a9c1e5b2d84670a1c3e5f7b9d02468ace13579bdf02468ace13579bdf0246',
    'payer_first_name' => 'John',
    'payer_last_name' => 'Smith',
    'payer_email' => '[email protected]',
    'payer_phone' => '998901234567',
    'payer_country' => 'UZ',
    'payer_city' => 'Tashkent',
    'payer_address' => '5 Amir Temur Ave',
    'payer_zip' => '100000',
    'payer_ip' => '203.0.113.10',
    'term_url_3ds' => 'https://yoursite.example/3ds-return',
    'hash' => 'b803e9b0f8c69a391be0fe34b77eb0ca',
];

$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': 'SALE',
    'client_key': '{CLIENT_KEY}',
    'order_id': 'TAP-1003',
    'order_amount': '120000',
    'order_currency': 'UZS',
    'order_description': 'Order TAP-1003',
    'card_token': '7f3a9c1e5b2d84670a1c3e5f7b9d02468ace13579bdf02468ace13579bdf0246',
    'payer_first_name': 'John',
    'payer_last_name': 'Smith',
    'payer_email': '[email protected]',
    'payer_phone': '998901234567',
    'payer_country': 'UZ',
    'payer_city': 'Tashkent',
    'payer_address': '5 Amir Temur Ave',
    'payer_zip': '100000',
    'payer_ip': '203.0.113.10',
    'term_url_3ds': 'https://yoursite.example/3ds-return',
    'hash': 'b803e9b0f8c69a391be0fe34b77eb0ca',
}

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': 'SALE',
  'client_key': '{CLIENT_KEY}',
  'order_id': 'TAP-1003',
  'order_amount': '120000',
  'order_currency': 'UZS',
  'order_description': 'Order TAP-1003',
  'card_token': '7f3a9c1e5b2d84670a1c3e5f7b9d02468ace13579bdf02468ace13579bdf0246',
  'payer_first_name': 'John',
  'payer_last_name': 'Smith',
  'payer_email': '[email protected]',
  'payer_phone': '998901234567',
  'payer_country': 'UZ',
  'payer_city': 'Tashkent',
  'payer_address': '5 Amir Temur Ave',
  'payer_zip': '100000',
  'payer_ip': '203.0.113.10',
  'term_url_3ds': 'https://yoursite.example/3ds-return',
  'hash': 'b803e9b0f8c69a391be0fe34b77eb0ca',
}

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 signature changes. A token sale uses the Formula 1 variant, over payer_email, PASSWORD and the reversed token, with no card fragment. Signing a token sale with the card formula is the single most common integration failure at this step, and it produces an authentication error rather than anything that points at the cause.

The precedence rules are unforgiving and silent. If you send both card_token and card data, the token is ignored. If you send both req_token and card_token, req_token is ignored. Nothing warns you.

Tokens are for merchant-initiated charging on your own schedule. If you want the platform to run the schedule, that is RECURRING_SALE, in the recurring guide.

Routing to a sub-merchant#

If you are a PSP or a platform, send channel_id to attribute a transaction to one of your sub-merchants. Maximum sixteen characters.

  -d "channel_id=SUBM-0042"

It is echoed in the callback and available in reporting, so it is what you reconcile on at sub-merchant level. Set it on every transaction from the start. Backfilling attribution after the fact is not possible.

Idempotency: order IDs, timeouts and duplicates#

order_id must be unique for every payment you create. Reusing one 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. Blind resubmission creates duplicate orders, and where cascading is enabled a single payment request can already have generated several underlying transactions, so a retry can multiply rather than repeat.

What next#

Technical questions go to [email protected].