Commerce · We run it ourselves
A UAE payment processor, licensed by the Central Bank of the UAE, that takes cards and wallets through a hosted page created by a single API call.
Choosing a locally licensed processor settles one question — the buyer pays in dirhams with a card their bank recognises, and the money lands in a UAE account without a cross-border leg. Everything after that is engineering, and it is the part every checkout gets wrong in the same three places: who states the amount, what counts as proof of payment, and who is allowed to mint a charge. This site takes real money through Ziina, so those three answers are load-bearing rather than theoretical.
01
The amount is decided on the server and never read from the request body. The route accepts a name for the thing being bought and nothing else; the price table lives in lib/ziina.ts, where express-site is 1,500,000 and the client cannot reach it. A sum that arrives in a POST is a sum the buyer can edit, and the entire premise of a hosted payment page is that the merchant states the figure.
02
Prices are held as integer fils, not dirhams. Ziina takes base units natively — AED 15,000 is 1,500,000 — so there is no conversion at the boundary at all: the integer in the price table is the integer in the request. A float that has been through a currency conversion is a float that will eventually be a fil short, and the discrepancy surfaces in reconciliation months later rather than at the till.
03
Test mode follows the deployment, not the request. Ziina exposes test as a per-intent flag, and with it set any card number completes a payment. Deciding that per request would mean a parameter a caller can send, and a caller who can send it takes the goods without paying — so the flag is bound to VERCEL_ENV === "production" in one place and is not otherwise reachable.
04
The success redirect is treated as a redirect, not a receipt. /pay/done is a guessable address that anyone can open, so the page asks Ziina what actually happened to the intent and renders that answer; a fabricated visit shows exactly what an unpaid one shows. Delivery hangs off the server-to-server webhook instead, and the webhook body is not trusted either: an HMAC-SHA256 signature compared in constant time, and then a second call to Ziina asking the intent's real status. A forged body is useless even if the signature check is misconfigured, because the second check never reads the body.
05
Only completed is money. Ziina documents six states and five of them are stages — requires_payment_instrument, requires_user_action, pending, failed, canceled. Anything that is not completed is treated as not paid, because a stage announced as a payment is a payment that later has to be un-announced to a client who has already been thanked.
06
The payments module is deliberately not a "use server" file. That directive publishes every export as an endpoint the browser may call, and paymentStatus takes an intent id — which would hand anyone the ability to read anyone else's payment by guessing or harvesting one. These functions run on the server because the server is the only thing that imports them, which is a structural property rather than a convention someone has to remember. The API key is a bearer token with authority to create charges, so it stays in the environment and never appears in the browser, in a log, or in an error returned to a caller.
07
A link for an arbitrary sum exists as a script with no URL. An HTTP endpoint that mints an intent for any amount is only ever as safe as whatever guards it, and it is an endpoint a stranger can point at their own success URL to dress a page in this account's name. Invoices and deposits are issued from the command line by a person who already holds the key, and the amount is converted from dirhams in exactly one place because every mistake in this family is a factor of a hundred.
An entry that cannot point at something you can open, run or read does not compile. That is a property of the type, not a promise in a paragraph.
Invariant
It cannot bill a retainer. With no customer object, no stored instrument and no subscription resource, every cycle is a fresh intent, a fresh link and a client who has to open it and re-enter a card — which is fine for a one-off fixed fee and unacceptable for anything charged monthly for a year. The currency story bounds it further: the merchant is a UAE entity, and while payment links reach roughly ten currencies, presenting in one costs 1.5% on top of the standard 2.6% + AED 1, with a further 1.5% for an international card. A European client on a twelve-month contract therefore pays a conversion spread every month for the privilege of a link, and at that point a bank transfer against an invoice is both cheaper and less work — which is why this site keeps an invoice generator alongside the payment button rather than routing everything through the processor.