Bill recurring#
Charge a card you have already taken once, on your own schedule or on one the platform runs for you.
Recurring billing has two halves. RECURRING_SALE charges a stored card, and you decide when. Schedule operations hand that timing to the platform. Most integrations need only the first.
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.
Store three things, or you cannot charge again#
Add recurring_init=Y to the initial SALE. On success, the response and the callback carry a recurring_token.
Store that token with the card's first six and last four digits and the payer email. All three are needed to compute the hash on every subsequent charge, and only the first one is obviously a credential.
This is the single most consequential paragraph on the page. RECURRING_SALE signs with Formula 1, which requires a card fragment, and RECURRING_SALE does not send card fields. The digits come from the mask on the initial callback. Discard that mask and the stored token is unusable, with no way to recover it from the platform.
In the sandbox, only the test card with expiry 01/2038 returns a recurring_token. The other expiries will not give you one to test with.
Charging the stored card#
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=RECURRING_SALE" \
-d "client_key={CLIENT_KEY}" \
-d "order_id=REC-4001" \
-d "order_amount=120000" \
-d "order_description=Subscription REC-4001" \
-d "recurring_first_trans_id=b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e" \
-d "recurring_token={RECURRING_TOKEN}" \
-d "hash=c8b58f1a6a6083fd4f0bd17d3ef58a45"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'RECURRING_SALE',
'client_key' => '{CLIENT_KEY}',
'order_id' => 'REC-4001',
'order_amount' => '120000',
'order_description' => 'Subscription REC-4001',
'recurring_first_trans_id' => 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'recurring_token' => '{RECURRING_TOKEN}',
'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': 'RECURRING_SALE',
'client_key': '{CLIENT_KEY}',
'order_id': 'REC-4001',
'order_amount': '120000',
'order_description': 'Subscription REC-4001',
'recurring_first_trans_id': 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'recurring_token': '{RECURRING_TOKEN}',
'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': 'RECURRING_SALE',
'client_key': '{CLIENT_KEY}',
'order_id': 'REC-4001',
'order_amount': '120000',
'order_description': 'Subscription REC-4001',
'recurring_first_trans_id': 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'recurring_token': '{RECURRING_TOKEN}',
'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()order_id must be new and unique for every charge. order_amount and order_description are required. recurring_first_trans_id is the trans_id of the initial transaction, not of the previous recurring charge.
Optional: schedule_id to link the charge to a schedule, auth=Y for authorisation only, and custom_data, which overrides the initial sale's custom_data rather than merging with it.
The response is identical to a SALE response but with action: RECURRING_SALE.
Recurring bypasses 3DS
Recurring transactions bypass 3D Secure. Two things follow from that, and neither is optional.
Your agreement with the cardholder must authorise recurring charges. And your acquiring setup must support merchant-initiated transactions.
The callback verifies with Formula 2
The request signs with Formula 1. The callback verifies with Formula 2, which adds the trans_id, and it carries the same parameters as a SALE callback.
action=RECURRING_SALE result=SUCCESS status=SETTLED order_id=REC-4001 trans_id=b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e amount=120000 currency=UZS card=411111****1111 hash=3864fecfc87b4767f7073eeed16f47c1
Two constructions again, one outbound and one inbound. The pattern holds across every operation in these guides.
Retrying a soft decline#
RETRY re-attempts a declined recurring transaction.
Only soft declines will succeed. Hard declines, such as a stolen card or fraud, will not, and retrying them is wasted traffic that an acquirer notices.
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=RETRY" \
-d "client_key={CLIENT_KEY}" \
-d "trans_id=b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e" \
-d "hash=c8b58f1a6a6083fd4f0bd17d3ef58a45"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'RETRY',
'client_key' => '{CLIENT_KEY}',
'trans_id' => 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'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': 'RETRY',
'client_key': '{CLIENT_KEY}',
'trans_id': 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'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': 'RETRY',
'client_key': '{CLIENT_KEY}',
'trans_id': 'b8c9d0e1-2f3a-4b5c-9d6e-7f8a9b0c1d2e',
'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()trans_id is the declined recurring transaction. Signed with Formula 1, which is why the digest matches the RECURRING_SALE above: same email, same password, same card fragment, and Formula 1 takes nothing else.
The synchronous response is result: ACCEPTED with order_id and trans_id.
Callbacks are verified with Formula 2. Success carries status: SETTLED with amount and currency. Declined carries status: DECLINED with decline_reason alongside them.
Letting the platform run the schedule#
Four operations manage a schedule, and a fifth detaches a card from one.
Creating a schedule
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=CREATE_SCHEDULE" \
-d "client_key={CLIENT_KEY}" \
-d "name=Monthly subscription" \
-d "interval_length=1" \
-d "interval_unit=month" \
-d "day_of_month=15" \
-d "payments_count=12" \
-d "hash=fc9e973f07b1a3f839991328a8d07487"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'CREATE_SCHEDULE',
'client_key' => '{CLIENT_KEY}',
'name' => 'Monthly subscription',
'interval_length' => '1',
'interval_unit' => 'month',
'day_of_month' => '15',
'payments_count' => '12',
'hash' => 'fc9e973f07b1a3f839991328a8d07487',
];
$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': 'CREATE_SCHEDULE',
'client_key': '{CLIENT_KEY}',
'name': 'Monthly subscription',
'interval_length': '1',
'interval_unit': 'month',
'day_of_month': '15',
'payments_count': '12',
'hash': 'fc9e973f07b1a3f839991328a8d07487',
}
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': 'CREATE_SCHEDULE',
'client_key': '{CLIENT_KEY}',
'name': 'Monthly subscription',
'interval_length': '1',
'interval_unit': 'month',
'day_of_month': '15',
'payments_count': '12',
'hash': 'fc9e973f07b1a3f839991328a8d07487',
}
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()name, interval_length, interval_unit and payments_count are all required. interval_unit is day or month, and interval_length is a number greater than zero, so 15 with day means every fifteen days.
day_of_month applies only when interval_unit is month. Set it to 29, 30 or 31 and a shorter month uses its last day.
delays is the number of intervals to skip before the schedule starts.
The response returns a schedule_id.
Formula 3 is the whole signature, and it is the reversed password, uppercased, hashed. No account key, no schedule, no card. It is therefore identical for every CREATE_SCHEDULE you ever send.
SANDBOX_PASSWORD is already uppercase, so the digest above is the same whether you uppercase the reversed password or not, and it cannot tell a correct implementation from one that skips strtoupper. Check yours against a mixed-case password too. With PASSWORD of Sandbox_Pass1 the correct construction gives 2bf5b69b57d942e1ec9d207129439d2e. Omitting the uppercasing gives 1230c4aa6b9db6317007ebe15cf9934a.
Pausing, running, deleting, inspecting
PAUSE_SCHEDULE, RUN_SCHEDULE, DELETE_SCHEDULE and SCHEDULE_INFO all take the same three fields: action, client_key and schedule_id.
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=PAUSE_SCHEDULE" \
-d "client_key={CLIENT_KEY}" \
-d "schedule_id=sch_9f2c41a8" \
-d "hash=e9dedc0029fed85cda02ae74b67f38dc"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'PAUSE_SCHEDULE',
'client_key' => '{CLIENT_KEY}',
'schedule_id' => 'sch_9f2c41a8',
'hash' => 'e9dedc0029fed85cda02ae74b67f38dc',
];
$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': 'PAUSE_SCHEDULE',
'client_key': '{CLIENT_KEY}',
'schedule_id': 'sch_9f2c41a8',
'hash': 'e9dedc0029fed85cda02ae74b67f38dc',
}
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': 'PAUSE_SCHEDULE',
'client_key': '{CLIENT_KEY}',
'schedule_id': 'sch_9f2c41a8',
'hash': 'e9dedc0029fed85cda02ae74b67f38dc',
}
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()These sign with Formula 4, the reversed concatenation of schedule_id and password, uppercased. Because the schedule id is inside it, this digest changes per schedule where Formula 3 does not.
PAUSE_SCHEDULE can only be used when the schedule's paused parameter is N. A paused schedule cannot take new recurring payments until it is released with RUN_SCHEDULE.
SCHEDULE_INFO returns name, interval_length, interval_unit, day_of_month, payments_count, delays and paused.
Detaching a card
DESCHEDULE takes action, client_key, recurring_token and schedule_id, and signs with Formula 4 like the others.
Which formula, at a glance#
| Operation | Signs with |
|---|---|
RECURRING_SALE | Formula 1 |
RETRY | Formula 1 |
CREATE_SCHEDULE | Formula 3 |
PAUSE_SCHEDULE, RUN_SCHEDULE, DELETE_SCHEDULE, SCHEDULE_INFO | Formula 4 |
DESCHEDULE | Formula 4 |
| Every callback above | Formula 2 |
What next#
Take a payment
covers the initial sale that produces the token, including req_token for merchant-initiated charging without a schedule.
Accept wallets
covers the virtual and card flow choice, which limits recurring on wallet payments.
The API reference
lists every parameter of every action. Idempotency covers order IDs, timeouts and what to do when an outcome is unknown.
Technical questions go to [email protected].