How mada payments reach your app
mada is Saudi Arabia's national debit card network. Your app never talks to mada directly. A payment gateway licensed to operate in the Kingdom connects to mada, Visa, Mastercard and wallets such as Apple Pay, and gives you an API. Moyasar, HyperPay, Tap and PayTabs are among the gateways Saudi merchants use; compare their fees, settlement times and onboarding requirements before choosing.
AI builders are quick to produce a checkout page. The risk is that they also produce a checkout that trusts the browser: the page says "payment succeeded" and the order is marked paid, with nothing on the server checking. Anyone can fake that. This guide is about the server side that makes payments real.
The safe flow in five parts
Create the order on your server
The server computes the total from its own product prices, in halalas, and saves an order with status
pending. Never accept a price sent by the browser.Collect the card in the gateway's form
Use the gateway's hosted page or official payment form. Card numbers go straight to the gateway, so they never touch your server and you stay out of most card-data compliance scope.
Handle the return
After the customer pays, the gateway redirects back with a payment ID. Treat this only as a hint: show "checking your payment" and verify on the server.
Verify server to server
Your server fetches the payment from the gateway API with the secret key and checks status, amount, currency and that it belongs to this order.
Confirm through the webhook
The gateway also calls your webhook URL. Check its signature, look the payment up, and mark the order paid once, even if the webhook arrives twice.
Illustrative server code (Node and Express)
This code is a gateway-neutral sketch. Endpoint paths, field names, amount units and the signature scheme differ between gateways; follow your gateway's official documentation for the exact values.
Creating the order. The amount is calculated on the server and stored in halalas (1 SAR = 100 halalas), the smallest-unit format many gateways expect:
import express from "express";
import { db } from "./db.js";
const app = express();
app.use("/api/payments/webhook", express.raw({ type: "application/json" }));
app.use(express.json());
const VAT = 0.15;
app.post("/api/orders", async (req, res) => {
const items = req.body.items; // [{ productId, qty }]
let subtotal = 0;
for (const { productId, qty } of items) {
const product = await db.getProduct(productId); // price from YOUR database
if (!product || qty < 1) return res.status(400).json({ error: "invalid item" });
subtotal += product.priceHalalas * qty;
}
const total = Math.round(subtotal * (1 + VAT));
const order = await db.createOrder({ items, total, currency: "SAR", status: "pending" });
res.json({ orderId: order.id, amount: total, currency: "SAR" });
});Verifying a payment server to server. The secret key comes from an environment variable and is only used here:
const GATEWAY_URL = process.env.GATEWAY_API_URL;
const SECRET = process.env.GATEWAY_SECRET_KEY;
async function fetchPayment(paymentId) {
const r = await fetch(`${GATEWAY_URL}/payments/${encodeURIComponent(paymentId)}`, {
headers: { Authorization: "Basic " + Buffer.from(SECRET + ":").toString("base64") },
});
if (!r.ok) throw new Error("gateway lookup failed");
return r.json();
}
async function settle(orderId, paymentId) {
const order = await db.getOrder(orderId);
if (!order || order.status === "paid") return order;
const payment = await fetchPayment(paymentId);
const ok = payment.status === "paid"
&& payment.amount === order.total
&& payment.currency === "SAR"
&& String(payment.metadata?.orderId) === String(order.id);
return ok ? db.markPaid(order.id, paymentId) : db.markFailed(order.id, payment.status);
}
app.get("/api/payments/return", async (req, res) => {
const order = await settle(req.query.orderId, req.query.id);
res.redirect(order?.status === "paid" ? "/thanks" : "/checkout?failed=1");
});Receiving the webhook. Verify the signature on the raw body before trusting anything in it, then reuse the same settle function so both paths agree:
import crypto from "node:crypto";
app.post("/api/payments/webhook", async (req, res) => {
const signature = req.get("x-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.GATEWAY_WEBHOOK_SECRET)
.update(req.body) // raw Buffer, not parsed JSON
.digest("hex");
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
await settle(event.data.metadata.orderId, event.data.id);
res.sendStatus(200); // answer fast; the gateway may retry otherwise
});Keeping secret keys secret
Gateways give you two kinds of keys. The publishable key can appear in the browser; the secret key can move money and must live only on the server. Never paste a secret key into a prompt, a chat message, a front-end file or a public repository.
In Uttra, when a project needs a key, a masked form appears inside the chat; the value is saved straight into a separate secrets vault and exposed to the project as an environment variable, so it never shows in the conversation or the published code. You can also add keys yourself under Secrets. Use sandbox keys while building and switch to live keys only after testing.
Apple Pay, refunds and the details people forget
- Apple Pay on the web usually requires verifying your domain with the gateway, so do it after the app is published on its final domain. See the Arabic Apple Pay setup guide.
- Refunds should go through your server and the gateway API, then update the order status, so your records and the gateway's match.
- Duplicate webhooks are normal. Make
settleidempotent: an order already marked paid stays paid and is not processed twice. - Invoices. If you are VAT registered, every paid order needs a ZATCA-compliant invoice. See ZATCA e-invoicing.
- Testing. Run the whole flow with the gateway's test cards, including a declined card and a closed browser tab before the redirect, to confirm the webhook still settles the order.
A prompt that asks for the safe version
When you ask an AI builder for payments, name the safety rules, or you may get a browser-only checkout:
أضف الدفع بمدى و Apple Pay عبر بوابة الدفع التي أحدّدها: احسب المبلغ في الخادم من أسعار قاعدة البيانات بالهللة مع ضريبة 15%، اعرض نموذج البوابة الرسمي فقط، تحقّق من الدفع من الخادم بالمفتاح السري المحفوظ كمتغيّر بيئة، وأضف Webhook يتحقق من التوقيع ويعلّم الطلب مدفوعًا مرة واحدة فقط.
The broader Arabic walkthrough is in the mada integration guide, and building an app in Saudi Arabia puts payments in context with domains, invoicing and data law.
When to use Uttra for this
Uttra suits this work because each project has a real server and database, keys go into a secrets vault rather than code, and you can review every file or edit it in the terminal. Whatever tool builds it, test the full flow with sandbox keys and read the payment code before switching to live keys; payment logic is the one place where a review is not optional.