CentaPay

Last updated

Pay out to a card#

Push funds from your settlement balance to a recipient's card.

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.

CREDIT2CARD is the payout product. It runs on the same endpoint as everything else and looks like a sale turned around, but it signs differently at both ends and it is the one action whose callback does not use Formula 2.

Every example below uses the same sample credentials so the hashes are reproducible: a PASSWORD of SANDBOX_PASSWORD and the sandbox payout test card. Payload values are illustrative, but every field name, its presence or absence, and every hash is exact.

payer_* is your business, not a person#

The request carries both payee_* and payer_* name and address blocks. The payee_* fields describe the recipient. The payer_* fields describe the sender.

Populate payer_* with the funding business, never with an individual.

A payout carrying an individual's name and address in the sender fields looks like a person-to-person transfer in the acquirer's data, whatever your intent. That is a different product with different rules, and the classification is made from what you send.

Every one of these fields is optional. Sending nothing is safer than sending a consumer's details.

The request#

POSThttps://{PAYMENT_URL}/post
curl -X POST https://{PAYMENT_URL}/post \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=CREDIT2CARD" \
  -d "client_key={CLIENT_KEY}" \
  -d "order_id=PAY-5001" \
  -d "order_amount=240000" \
  -d "order_currency=UZS" \
  -d "order_description=Payout PAY-5001" \
  -d "card_number=4601541833776519" \
  -d "payee_first_name=Jane" \
  -d "payee_last_name=Doe" \
  -d "payer_first_name=Example" \
  -d "payer_last_name=Trading LLC" \
  -d "hash=96cac979f21c03eb2a3fc5c4415fbfce"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'CREDIT2CARD',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'PAY-5001',
    'order_amount' => '240000',
    'order_currency' => 'UZS',
    'order_description' => 'Payout PAY-5001',
    'card_number' => '4601541833776519',
    'payee_first_name' => 'Jane',
    'payee_last_name' => 'Doe',
    'payer_first_name' => 'Example',
    'payer_last_name' => 'Trading LLC',
    'hash' => '96cac979f21c03eb2a3fc5c4415fbfce',
];

$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': 'CREDIT2CARD',
    'client_key': '{CLIENT_KEY}',
    'order_id': 'PAY-5001',
    'order_amount': '240000',
    'order_currency': 'UZS',
    'order_description': 'Payout PAY-5001',
    'card_number': '4601541833776519',
    'payee_first_name': 'Jane',
    'payee_last_name': 'Doe',
    'payer_first_name': 'Example',
    'payer_last_name': 'Trading LLC',
    'hash': '96cac979f21c03eb2a3fc5c4415fbfce',
}

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': 'CREDIT2CARD',
  'client_key': '{CLIENT_KEY}',
  'order_id': 'PAY-5001',
  'order_amount': '240000',
  'order_currency': 'UZS',
  'order_description': 'Payout PAY-5001',
  'card_number': '4601541833776519',
  'payee_first_name': 'Jane',
  'payee_last_name': 'Doe',
  'payer_first_name': 'Example',
  'payer_last_name': 'Trading LLC',
  'hash': '96cac979f21c03eb2a3fc5c4415fbfce',
}

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

Required: action, client_key, order_id, order_amount, order_currency, order_description, card_number and hash. Everything else is optional, including every payee_* and payer_* field.

order_amount is an integer for KZT and UZS and a float formatted XX.XX for USD, exactly as on the acceptance side. channel_id routes the payout to a sub-account and accepts up to sixteen characters.

Formula 5 has no email in it

A payout signs with Formula 5:

md5(strtoupper(
  PASSWORD . strrev(substr(card_number, 0, 6) . substr(card_number, -4))
))

Password first, then the reversed card fragment. There is no email term, unlike Formula 1, and no trans_id, because the transaction does not exist yet.

If you are paying out to a stored token rather than a PAN, the variant is md5(strtoupper(PASSWORD . strrev(card_token))).

The digest above cannot prove your uppercasing is right. SANDBOX_PASSWORD is already uppercase and the card fragment is digits, so strtoupper changes nothing and an implementation that omits it produces the same value. Check yours against a mixed-case password. With PASSWORD of Sandbox_Pass1 and the same card, the correct construction gives ffaa6f8851f5c3f70e47f6c968ac13a0. Omitting the uppercasing gives 04f837678afe4237202262551c68fbba.

The synchronous response#

Every CREDIT2CARD synchronous response carries action, result, status, order_id, trans_id and trans_date.

Unlike a SALE, the success response does not include amount or currency. If your handler reads those fields unconditionally it will break here, and it will break on the success path rather than the error path.

ResultStatusAlso carries
SUCCESSSETTLEDdescriptor
DECLINEDDECLINEDdecline_reason
UNDEFINEDPREPAREdescriptor, if available

The callback uses Formula 6, and only this action does#

This is the fact to take away from the page.

md5(strtoupper(
  PASSWORD . trans_id . strrev(substr(card_number, 0, 6) . substr(card_number, -4))
))

Formula 6 is Formula 5 with the trans_id inserted between the password and the card fragment. Still no email.

CREDIT2CARD is the only action whose callback hash formula differs from Formula 2. An integration that verifies every callback with one shared routine will pass on all of them and fail on this one, which is a difficult failure to find because it looks like a payout-specific problem rather than a hashing one.

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
hash=459f391fad455334d8e40d69539125df

Declined callbacks add decline_reason. Undefined callbacks carry status: PREPARE. All three verify with Formula 6.

If your account has extended data enabled, success callbacks also carry connector_name, rrn, approval_code and related acquirer fields.

The two digests on this page share a password and a card and differ only because Formula 6 adds the trans_id. That is the check a reader can run against their own implementation.

Testing#

The sandbox payout card is 4601541833776519, which returns SUCCESS with status: SETTLED.

Sandbox scheme coverage is a testing convenience and does not indicate which schemes are enabled on your live account.

Paying out to something other than a card#

CREDIT2VIRTUAL covers payouts to mobile money, bank transfer and other virtual account methods.

It is not yet available on CentaPay. Contact [email protected] if you need it, so the requirement is on record.

What next#

Technical questions go to [email protected].