Skip to main content

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

  1. Your business name and logo, the order reference and a countdown to expires_at.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

Coming back to your site
The customer leaves fromThey go to
The confirmation of a successful paymentreturn_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:

Text
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)
A valid signature proves the link came from Bezanbecard, not that the payment is paid now. Before you ship an order, read the payment with 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:

Building your own checkout
FieldDescription
expected_amountThe exact amount to send, in rial. Show it with the rial figure; never round it.
payment_method_typescard, transfer or both: which of the two tabs to offer.
destination.typecard (a bank card) or account (an IBAN with no card).
destination.panThe full card number, or null for an account or when the payment takes no card-to-card transfer. Secret keys only.
destination.ibanThe 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_nameThe bank code, the card's and the IBAN's last four digits, and the holder name.
expires_atUnix 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 /v1/payments/:id/receipts (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.

Text
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.

The view model
FieldDescription
statusThe payment's status. processing means a receipt waits for the shop's review.
amount.requested, amount.received, amount.remainingIn rial.
amount.expectedThe 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_methodThe payment's methods, and the tab to open first: card whenever the payment takes cards. It never depends on the amount.
channelspol, 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_untilWhether a receipt is accepted now, and until when.
receipt_requiredWhether a receipt file is needed.
receiptsThe customer's receipts, oldest first: status, channel, amount, created_at and, when rejected, rejection.reason.
payer_requiredWhether 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_timeCount down from server_time, never from the device clock.
return_url, cancel_urlThe signed return links.
overpayment{ status, amount }: what the shop owes back, if anything.
merchant, reference, description, message, locale, unit, livemodeThe 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 /checkout/<token>/receipts takes multipart/form-data:

Sending a receipt
FieldDescription
filesUp 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.
channelcard, pol, paya, satna or internal. Defaults to card when the payment takes cards; required otherwise.
referenceThe tracking number on the receipt: 4 to 40 letters and digits. Persian digits are accepted.
amountThe rial the customer says they sent. Leave it out for the expected amount.
paid_atWhen they paid, ISO 8601.
payer_name, payer_last4Optional. payer_last4 is required on the card channel when payer_required.
name, phone, emailThe details in collect that the payment still needs.
replacetrue when this receipt corrects the customer's earlier pending one, rather than recording a second transfer.
curl
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:

Sending a receipt
StatusCodeWhen
409checkout.closedThe checkout no longer takes a receipt; payload.status says why.
400checkout.field_requiredpayload.param names what is missing: files, channel, payer_last4, name, phone or email.
400checkout.channel_unavailableThe payment does not take that kind of transfer.
400file.too_many, file.invalid_type, file.invalid_uploadMore than three files, a file that is not an image or PDF, or a body that cannot be read (send the files in files).
413file.too_largeA file is larger than 10 MB.
429Too 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.