BestPDF

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.

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-23456789abcd

Availability

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:

FieldTypeRequiredNotes
recommended_amountstringyesDecimal string with scale 2, e.g. "7300.00". The amount you would like to receive.
min_amountstringnoLower bound on the amount the payer may be offered.
max_amountstringnoUpper 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-Key missing, 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.

On this page