Hosted checkout and the return redirect
What your customer sees at checkout_url, the two payment tabs and the receipt step, the checkout API behind the page, and how to verify the signed return link.
Every payment has a checkout_url on bezanbecard.com/pay/…. Send your customer there and Bezanbecard does the rest: it shows the amount and where to pay it, takes the customer's receipt, follows the payment live and sends the customer back to you when it is done. The page works on any phone, in Persian and English, in light and dark.
What the customer sees
- Your business name and logo, the order reference and a countdown to
expires_at. - The exact amount to send, in toman with the exact rial figure under it. This is
expected_amount, unique to this payment unless you turned unique amounts off. - Two tabs directly above the destination, Card to card and Bank transfer, when the payment takes both methods; one tab when it takes one. The card tab opens first whenever the payment takes cards.
- Where to pay. The card tab shows the bank, the full card number and the holder name. The bank transfer tab shows the IBAN in two lines, the holder and the bank, and the transfer kinds the customer may use (Pol, Paya, Satna or a same-bank transfer) with a short, hedged note on each. Both tabs have buttons that copy the number and the amount.
- The receipt step. After paying, the customer uploads up to three files (a photo, a screenshot or a PDF of the bank receipt) and presses "Send receipt". On the bank transfer tab they say which kind of transfer they made; nothing is preselected. They may add the tracking number from the receipt.
- Any of name, mobile number and email you ask for in your checkout settings and the payment does not already have, and the last four digits of the paying card if you ask for them (off by default: the receipt already shows the card).
A receipt file is required by default. Turn that off in your checkout settings and the customer may press "Send receipt" without one; your team then checks the bank account by hand, or the optional SMS app finds the transfer.
After "Send receipt" the page shows that the payment is waiting for your approval, with each receipt the customer sent and its status. It updates by itself the moment your team decides. A rejected receipt shows its reason, and the customer can send another. A partial approval shows a progress bar with what arrived and a fresh unique amount for the rest. A successful payment shows a confirmation, with the message you set on a payment link if it came from one. A test payment carries a ribbon so nobody mistakes it for a real one.
The card number and the IBAN are shown only while the payment is waiting for a transfer. Once it succeeds, closes or its grace period ends, the page shows the bank and the last four digits only.
Hints, never rules
Banks and the Central Bank set the amount limits and working hours of card-to-card, Pol, Paya and Satna transfers, and change them without notice. The checkout never hides, disables, refuses or preselects anything because of them, and never quotes a limit or an hour. When an amount looks large for a card-to-card transfer, the card tab says softly that a bank transfer may be easier, and the bank transfer tab marks the transfer kinds that may suit the amount as suggested. The customer's bank decides. See bank transfers.
Coming back to your site
| The customer leaves from | They go to |
|---|---|
| The confirmation of a successful payment | return_url |
| Any other state (they gave up, the payment expired or was canceled) | cancel_url, or return_url when you set no cancel_url |
Bezanbecard adds three query parameters to either URL and keeps any query you put there yourself:
https://shop.example/orders/1042/thanks?payment=pay_8xkq2LmZr0Pa1Bcd&status=succeeded&signature=3f0c…A customer who leaves while the receipt waits for your review returns with status=processing: show them that the order is waiting for confirmation, and update it from the webhook.
Verifying the signature
signature is the lowercase hex HMAC-SHA256 of <payment id>.<status>, keyed with your redirect signing secret (rds_…). Each mode has its own secret. Reveal it under Developers → API keys → Redirect signing secret; the store plugins receive it when you connect them.
Compare in constant time, and refuse the link when it does not verify.
import crypto from 'node:crypto';
function verifyReturn(query, secret) {
const [payment, status, signature] = ['payment', 'status', 'signature'].map((name) => String(query[name] ?? ''));
const expected = crypto.createHmac('sha256', secret).update(`${payment}.${status}`).digest('hex');
return Buffer.byteLength(signature) === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Express: verifyReturn(req.query, process.env.BEZANBECARD_REDIRECT_SECRET)GET /v1/payments/:id (or rely on the payment.succeeded webhook) and check its status and amount_received.Building your own checkout
You can show the payment inside your own page instead. With a secret key, the payment carries everything the customer needs:
| Field | Description |
|---|---|
expected_amount | The exact amount to send, in rial. Show it with the rial figure; never round it. |
payment_method_types | card, transfer or both: which of the two tabs to offer. |
destination.type | card (a bank card) or account (an IBAN with no card). |
destination.pan | The full card number, or null for an account or when the payment takes no card-to-card transfer. Secret keys only. |
destination.iban | The full IBAN, or null when the payment takes no bank transfer or the destination has none. Secret keys only. |
destination.bank, destination.last4, destination.iban_last4, destination.holder_name | The bank code, the card's and the IBAN's last four digits, and the holder name. |
expires_at | Unix seconds when the checkout closes. |
Restricted keys and webhooks never carry pan or iban. Keep both out of your logs and analytics.
Then collect the receipt and send it from your server with POST / (see receipts), or straight from the customer's browser to the checkout API below, which needs no key. Listen to webhooks for the outcome, and when the payment becomes Partially paidpartially_paid, show the new expected_amount for the rest.
The checkout API
The hosted page is built on a small public API, keyed by the token at the end of checkout_url. It needs no API key and answers any origin, so a page of yours can use it from the customer's browser without exposing a secret.
GET https://a.bezanbecard.com/checkout/<token> the view model
POST https://a.bezanbecard.com/checkout/<token>/receipts send a receipt (multipart)
POST https://a.bezanbecard.com/checkout/<token>/claim a receipt without a file (JSON)
GET https://a.bezanbecard.com/checkout/<token>/stream server-sent events named "status"Each answer is the view model. An unknown or malformed token is 404 with checkout.not_found. Refusals are a status and a dotted code, { "message": "checkout.closed", "payload": { "status": "succeeded" } }, unlike the error object of /v1.
The view model
Times are ISO 8601. The view never carries the payment id (outside the signed return links), a receipt id, a file or a hash.
| Field | Description |
|---|---|
status | The payment's status. processing means a receipt waits for the shop's review. |
amount.requested, amount.received, amount.remaining | In rial. |
amount.expected | The exact amount to transfer; null once the payment is terminal or past grace. It stays set while a receipt is under review (processing) and in requires_review, so show the transfer only for requires_payment and partially_paid while claimable is true, as the hosted checkout does. |
methods, default_method | The payment's methods, and the tab to open first: card whenever the payment takes cards. It never depends on the amount. |
channels | pol, paya, satna and internal when the payment takes bank transfers and expects an amount, else empty. Never filtered by amount or time. |
suggestion | { method, channels } or null: a soft hint for this amount. Show it gently; never hide, disable or preselect anything because of it. |
destination | { kind, bank, holder, pan, last4, iban, iban_last4 }. pan only for a card while the payment is open and takes cards; iban only while it is open and takes transfers. |
waiting | { channel } while processing: how the customer said they paid. |
claimable, claimable_until | Whether a receipt is accepted now, and until when. |
receipt_required | Whether a receipt file is needed. |
receipts | The customer's receipts, oldest first: status, channel, amount, created_at and, when rejected, rejection.reason. |
payer_required | Whether the last four digits of the paying card are needed on the card channel. |
collect | { name, phone, email }: true for each detail the payment still needs. |
review | { reason } while the payment is in review: no_sms, amount_mismatch, partial_disabled, expired_partial or null. |
expires_at, server_time | Count down from server_time, never from the device clock. |
return_url, cancel_url | The signed return links. |
overpayment | { status, amount }: what the shop owes back, if anything. |
merchant, reference, description, message, locale, unit, livemode | The shop, the order, a link's message after success, the page language, toman or rial, and the mode. |
With unique amounts off, amount.expected equals amount.requested, so there are no digits to point out to the customer.
Sending a receipt
POST / takes multipart/form-data:
| Field | Description |
|---|---|
files | Up to 3 files of at most 10 MB each: JPEG, PNG, WebP, HEIC or PDF, recognised by their bytes, never by their name. Required when receipt_required. |
channel | card, pol, paya, satna or internal. Defaults to card when the payment takes cards; required otherwise. |
reference | The tracking number on the receipt: 4 to 40 letters and digits. Persian digits are accepted. |
amount | The rial the customer says they sent. Leave it out for the expected amount. |
paid_at | When they paid, ISO 8601. |
payer_name, payer_last4 | Optional. payer_last4 is required on the card channel when payer_required. |
name, phone, email | The details in collect that the payment still needs. |
replace | true when this receipt corrects the customer's earlier pending one, rather than recording a second transfer. |
curl https://a.bezanbecard.com/checkout/<token>/receipts \
-F channel=paya \
-F reference=140508041234 \
-F [email protected]The payment becomes processing and the receipt waits in your review queue. Refusals:
| Status | Code | When |
|---|---|---|
| 409 | checkout.closed | The checkout no longer takes a receipt; payload.status says why. |
| 400 | checkout.field_required | payload.param names what is missing: files, channel, payer_last4, name, phone or email. |
| 400 | checkout.channel_unavailable | The payment does not take that kind of transfer. |
| 400 | file.too_many, file.invalid_type, file.invalid_upload | More than three files, a file that is not an image or PDF, or a body that cannot be read (send the files in files). |
| 413 | file.too_large | A file is larger than 10 MB. |
| 429 | Too many receipts: 10 an hour per payment and 30 an hour per visitor, or the server is busy reading uploads. Wait for Retry-After. |
A receipt without a file
POST /checkout/<token>/claim takes JSON { channel?, reference?, last4?, name?, phone?, email? } and records a receipt with no file. last4 belongs to the card channel and reference to the bank transfer channels. While receipt_required it is refused with checkout.field_required and param: "files". It shares the receipts' limits.