Hosted payment link
POST /v1/payment/link and POST /v1/payment/link/flexible
Two endpoints hand the payer a ready-made hosted checkout page instead of
requisites you render yourself. Both take the same request as
POST /v1/payment and answer 200 OK; they differ in who
decides the amount.
POST /v1/payment/link— the amount is the one you send, exactly likePOST /v1/payment.POST /v1/payment/link/flexible— you send a recommended amount, and when the matching pool cannot serve it the payer picks a nearby amount on the page.
Hosted payment page — POST /v1/payment/link
Instead of rendering the requisites yourself, you can hand the payer a
ready-made hosted checkout page — where the hosted page is enabled for
your account. POST /v1/payment/link takes the exact same request as
POST /v1/payment and creates the inbound payment identically — the only
difference is the response, which adds a payment_url field. The endpoint
always works and always creates the operation; if the hosted page is not
enabled for your account, payment_url is null and you should render the
requisites yourself from the rest of the response, exactly like
POST /v1/payment. Do not assume payment_url is present —
check for it before redirecting the payer.
POST /v1/payment/link
Content-Type: application/json
X-API-Key: 01234567-89ab-4cde-8f01-23456789abcd{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"type": "payment",
"status": "awaiting_confirmation",
"method": "WT_RUB_PHONE",
"expected_amount": "1500.00",
"currency": "RUB",
"payment_url": "https://pay.bestpdf.cc/f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"external_confirmation": { "required": true, "methods": ["receipt"] },
"number": "+79991234567",
"name": "Ivan Ivanov",
"bankname": "sber",
"bankname_details": "Сбербанк"
}When payment_url is present, redirect the payer to it. The hosted page shows the amount and
requisites, walks the payer through paying the exact amount, and — when
the route requires a receipt — accepts a PDF receipt upload and runs it
through the same checks as POST /v1/confirm_payment,
then reflects the outcome (paid / under review / declined / expired). You keep
receiving the authoritative result by webhook exactly as with
the server-to-server flow; the page is only an alternative front end for the
same operation.
payment_url is <host>/<operation_id>. Everything else in the response is
identical to POST /v1/payment. The plain /v1/payment response
never carries payment_url.
Flexible hosted link — POST /v1/payment/link/flexible
A hosted-page link for shops whose payin routing for the method goes
only through BestPDF's self-matching pool. Instead of a fixed amount, you
send a recommended_amount: if the pool can cover it right now, this
endpoint behaves exactly like /v1/payment/link above. If it cannot, but
other amounts are currently available, no payment is created yet — the
payer is shown an amount picker on the hosted page and chooses a nearby
amount themselves. If nothing is available at all, the response is
rejected and no payment is created.
POST /v1/payment/link/flexible
Content-Type: application/json
X-API-Key: 01234567-89ab-4cde-8f01-23456789abcdAvailability
This endpoint is per-shop opt-in, configured by BestPDF. Without the opt-in,
the endpoint still works but never shows a picker: it creates an ordinary
payment for recommended_amount and payment_url is always null, the same
degrade-to-plain-create behaviour as /v1/payment/link. Shops whose payin
route for the method includes any provider other than the self-matching
pool get 412.
Request body
Same body as POST /v1/payment, with amount replaced by
recommended_amount, plus two optional bounds:
| Field | Type | Required | Notes |
|---|---|---|---|
recommended_amount | string | yes | Decimal string with scale 2, e.g. "7300.00". The amount you would like to receive. |
min_amount | string | no | Lower bound on the amount the payer may be offered. |
max_amount | string | no | Upper bound on the amount the payer may be offered. |
All amounts must be greater than 0, min_amount must not exceed max_amount
when both are set, and recommended_amount must lie within whichever bounds
you set — otherwise 400.
curl -X POST https://api.bestpdf.cc/v1/payment/link/flexible \
-H "Content-Type: application/json" \
-H "X-API-Key: 01234567-89ab-4cde-8f01-23456789abcd" \
-d '{
"merchant_operation_id": "8a26f5cf-1f56-4cda-8d8a-2a915b5e4b58",
"client_id": "57b8a4ca-3a4b-4f01-9a5e-1f29c1cefb9b",
"client_ip": "203.0.113.10",
"method": "WT_RUB_PHONE",
"recommended_amount": "7300.00",
"min_amount": "5000.00",
"max_amount": "9000.00",
"currency": "RUB"
}'Responses
200 OK in all three cases; status (and whether payment_url is set)
tells you which one you got.
The recommended amount is available now — created exactly like
/v1/payment/link above, same response shape:
{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"type": "payment",
"status": "pending",
"method": "WT_RUB_PHONE",
"expected_amount": "7300.00",
"currency": "RUB",
"payment_url": "https://pay.bestpdf.cc/f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22?k=MuSwyMLZV9C_T0YToFjIpTOZXHWogLCD7Ik0PtnDmAU"
}The recommended amount is not available, other amounts are, and the
picker is enabled for the shop — status: "amount_selection":
{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"status": "amount_selection",
"method": "WT_RUB_PHONE",
"currency": "RUB",
"recommended_amount": "7300.00",
"payment_url": "https://pay.bestpdf.cc/f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22?k=MuSwyMLZV9C_T0YToFjIpTOZXHWogLCD7Ik0PtnDmAU",
"expires_at": "2026-09-21T12:45:00Z"
}payment_url carries a k query parameter, and that parameter is the
credential for the page. Pass the URL to the payer whole: strip the query
and the page answers "not found", because the operation_id on its own is
derived from your shop id and your merchant_operation_id and is therefore not
a secret. Treat the full URL like a password — do not log it and do not expose
it to a third party. Retrying the same merchant_operation_id returns the same
URL, so a page the payer already has open keeps working.
amount_selection is not an operation status — no payment exists yet.
Querying operation state for this operation_id (or waiting on a
webhook) returns 404 until the payer confirms an amount;
there is no callback for this state either. operation_id is stable: it is
the id the payment will be created with once an amount is confirmed, so you
can already store it against your order. expires_at is the link's
configured lifetime; if it elapses with no choice made, nothing is created
and no callback fires. On the hosted page the payer sees either a single
amount field or a list of available ranges, depending on how the shop is
configured.
Nothing is available — status: "rejected", no payment is created:
{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"status": "rejected",
"method": "WT_RUB_PHONE",
"currency": "RUB",
"recommended_amount": "7300.00",
"payment_url": null
}After the payer confirms
Once the payer picks an amount on the hosted page, the payment is created
with the chosen amount, not recommended_amount. From then on it
behaves like any other operation: the usual webhooks fire and carry the
actual processed amount — integrate against the callback amount, never
assume recommended_amount. If the chosen amount is taken by another payer
in the meantime, the page shows refreshed available amounts and the payer
picks again. In the rare case where the confirmed amount is claimed in the
last moment between the final availability check and payment creation, the
created payment can itself come back rejected, and you receive the
ordinary rejected webhook for it, exactly as for any other rejected
payment.
Idempotency
Resending the same request with the same merchant_operation_id returns the
original link or payment unchanged, exactly like /v1/payment and
/v1/payment/link.
Errors and limits
400— invalid JSON, a required field is missing/malformed, or the amount bounds are invalid.401/403—X-API-Keymissing, invalid, or not authorized.412— the shop's payin routing for this method is not entirely the self-matching pool, or the shop is not otherwise ready to create.429— rate limit.503— dependency unavailable or busy; retry later.