# Raïs API documentation > Raïs is a payment platform for Algeria. Businesses accept CIB and Edahabia card payments through a REST API, a hosted checkout and webhooks; amounts are in Algerian dinars (DZD), always integers in centimes. This file contains every page of the documentation. The index is at https://rais.app/llms.txt. --- # Introduction > What Raïs is, how a payment travels from your server to the bank and back, and the concepts you will meet. Source: https://rais.app/docs Raïs lets a business in Algeria accept CIB and Edahabia card payments in Algerian dinars. You create a payment from your server, send the payer to a hosted checkout, and learn the outcome through a signed webhook or the API. Raïs handles the card form, 3-D Secure with SATIM, receipts and refunds. ## How a payment works A payment, end to end. Actors: Your server, Payer, Raïs, SATIM. 1. Your server to Raïs: POST /v1/payments 2. Raïs to Your server: payment with checkout_url 3. Your server to Payer: Redirect to checkout_url 4. Payer to Raïs: Enters the card on the hosted page 5. Raïs to SATIM: Authorisation and 3-D Secure 6. SATIM to Raïs: Approved or declined 7. Raïs to Payer: Redirect to success_url or cancel_url 8. Raïs to Your server: Webhook payment.succeeded (signed) 9. Your server to Raïs: GET /v1/payments/{id} to confirm 1. Your server creates a **payment** with the amount and your own reference. Raïs answers with a `checkout_url`. 2. You redirect the payer to that URL. They enter their card on the hosted page and confirm with 3-D Secure. 3. The result page shows the payer the outcome and a button back to your `success_url` or `cancel_url`; independently, Raïs delivers a signed **webhook** to your server. 4. Your server confirms the result (from the webhook, or by retrieving the payment) before it ships anything. The browser redirect is only a convenience for the payer. The webhook and the API are the source of truth. ## Concepts ### Stores A **store** is the merchant account that takes payments. It has a name, return URLs, branding, API keys and webhook endpoints. A business account starts with one store and can have several; every API key belongs to exactly one store, and a key only ever sees the payments of its own store. ### API keys You call the API with a **secret key** sent as a bearer token. Sandbox keys start with `rais_sk_test_`, live keys with `rais_sk_live_`. Keys belong on your server: the API sends no CORS headers on purpose, so a browser or a mobile app cannot use one. See [Authentication](https://rais.app/docs/authentication.md). ### Test and live The sandbox and live environments have the same API, the same URLs and the same objects. The **key decides** which environment a request belongs to. Sandbox payments are paid with SATIM's published test cards and never move money; live payments are real and need a verified business. See [Test cards and sandbox](https://rais.app/docs/testing.md) and [Going live](https://rais.app/docs/going-live.md). ### Payments A payment is one attempt to collect an amount from a payer. Amounts are **integers in centimes**: `500000` is 5,000.00 DA. The only currency is `DZD` and the smallest payment is 50.00 DA. A payment is `pending` until it is paid, and ends `paid`, `failed`, `cancelled` or `expired`. See [Payments](https://rais.app/docs/payments.md). ### Refunds You can refund all or part of a paid payment, as many times as you like, never beyond what was paid. See [Refunds](https://rais.app/docs/refunds.md). ### Pricing A paid payment costs SATIM's fee plus Raïs's, a refund costs a flat 10.00 DA, and every price excludes VAT. A payment carries its `fees` and its `net`, a refund its own `fees`. See [Pricing and fees](https://rais.app/docs/pricing.md). ### Events and webhooks Every change that matters creates an **event** such as `payment.succeeded`. Raïs delivers events to your webhook endpoints, signed, at least once, and keeps them readable through the API for 30 days. See [Webhooks](https://rais.app/docs/webhooks.md). ### Receipts Every paid payment has a PDF receipt for the payer, available through a link on the payment and through a signed link you can request. See [Receipts](https://rais.app/docs/receipts.md). ## Conventions | | | | --- | --- | | Base URL | `https://api.rais.app/v1` | | Payer's pages | `https://pay.rais.app`: the hosted checkout and the receipts (`checkout_url`, `receipt_url`) | | Format | JSON requests and responses, UTF-8 | | Authentication | `Authorization: Bearer rais_sk_...` | | Amounts | Integers in centimes (1 DA = 100) | | Timestamps | Unix time in seconds | | Ids | Payments, refunds and stores are UUIDs; events start with `evt_` | | Errors | One envelope for every error, with a stable `code` and a `request_id` | | Version | `2026-10-26`, in the `Rais-Version` header of every response | ## Where to go next - [Quickstart](https://rais.app/docs/quickstart.md): create a test key, take a first payment and verify it. - [Authentication](https://rais.app/docs/authentication.md): keys, scopes, rotation and IP allowlists. - [Payments](https://rais.app/docs/payments.md): the payment object, statuses and everything you can set. - [Webhooks](https://rais.app/docs/webhooks.md): receive and verify events. - [API reference](https://rais.app/docs/api.md): every endpoint with examples in nine languages. - [Use these docs with AI](https://rais.app/docs/ai-tools.md): llms.txt, Markdown pages and the OpenAPI file. --- # Quickstart > Create a store, get a test key, take a first test payment and verify it, in about ten minutes. Source: https://rais.app/docs/quickstart This guide takes you from nothing to a verified test payment. You will need a Raïs business account and a server that can run a few lines of code. No money moves in the sandbox. ## 1. Get a test key A business account is created with one store, which can use the sandbox straight away. In the dashboard, open **Developers**, then **API keys**, press **Create key**, and choose your store and the **Test** mode in the dialog. A test key starts with `rais_sk_test_` and is shown **once**: copy it into your environment now. ~~~bash export RAIS_SECRET_KEY="rais_sk_test_..." ~~~ > [!WARNING] > **Keep the key on your server** > > Anybody who holds a secret key can create payments in your name. Never put it in a web page, a mobile app or a repository. See [Authentication](https://rais.app/docs/authentication.md). ## 2. Check the key Retrieving your account is the simplest call: it works with any valid key and tells you which store and environment the key belongs to. **Request** ```bash curl https://api.rais.app/v1/account \ -H "Authorization: Bearer rais_sk_test_..." ``` **Response** (`200`) ~~~json { "object": "account", "id": "7d0b6a3e-1c52-4f8e-9a44-2b6e8f1d0c35", "name": "Boutique Alger", "livemode": false, "live_enabled": false, "currency": "DZD", "payment_page": "rais" } ~~~ `livemode: false` confirms that this is a sandbox key. ## 3. Create a payment Create the payment **from your server**, with the amount in centimes. `500000` is 5,000.00 DA. The `Idempotency-Key` header makes the request safe to repeat: if the connection drops and you send it again with the same key, you get the same payment back instead of a second one. See [Idempotency and retries](https://rais.app/docs/idempotency.md). **Request** ```bash curl https://api.rais.app/v1/payments \ -H "Authorization: Bearer rais_sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b7e1c1c6-6f0a-4d2f-9d8b-3a5c2f1e7a90" \ -d '{ "amount": 500000, "currency": "DZD", "description": "Order #1042", "reference": "order-1042", "customer": { "name": "Amina Benali", "email": "amina@example.dz", "phone": "+213550000000" }, "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "metadata": { "cart_id": "c_8812" }, "expires_in": 3600 }' ``` **Response** (`201`) ~~~json { "id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "object": "payment", "status": "pending", "amount": 500000, "currency": "DZD", "description": "Order #1042", "reference": "order-1042", "customer": { "id": null, "name": "Amina Benali", "email": "amina@example.dz", "phone": "+213550000000" }, "items": [ { "name": "Running shoes", "description": null, "quantity": 2, "unit_amount": 250000 } ], "metadata": { "cart_id": "c_8812" }, "livemode": false, "payment_page": "rais", "checkout_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "expires_at": 1793003600, "created": 1793000000, "paid_at": null, "amount_refunded": 0, "refunded": false, "fees": null, "net": null, "receipt_url": null, "failure": null, "payment_method": null, "processor": { "name": "satim", "order_id": "V721uPPfNNofVQAAABL3", "approval_code": null, "response_code": null } } ~~~ The payment is `pending`. Keep its `id` and `reference`; you will need them to match the result to your order. ## 4. Send the payer to the checkout Redirect the payer to `checkout_url`. It is a hosted page, in English, French or Arabic, where they enter their card and confirm with 3-D Secure. When they are done, a result page shows the outcome and a button that takes them to your `success_url` or `cancel_url` with the payment id and a status added to the address. Some payers close the tab right after paying and never come back to your site: the webhook of step 6 still reaches you, which is why it matters. See [Hosted checkout and return URLs](https://rais.app/docs/checkout.md). ## 5. Pay with a test card The checkout of a sandbox payment is marked "Test mode". Type SATIM's test card as you would a real one (the cards are in [Test cards and sandbox](https://rais.app/docs/testing.md)). This one always succeeds: | Card number | Expiry (MM/YY) | CVV2 | 3-D Secure password | | --- | --- | --- | --- | | `6280 5811 1000 7215` | 01/27 | 373 | 123456 | The other cards reproduce declines, expired cards and wrong passwords: see [Test cards and sandbox](https://rais.app/docs/testing.md). ## 6. Receive the webhook Webhooks tell your server what happened without waiting for the payer's browser. In the dashboard, open **Developers**, then **Webhooks**, add an endpoint, choose your store and the **Test** mode in the dialog, and choose the events to receive (`payment.succeeded` is enough for now). Copy its **signing secret**: it starts with `whsec_`. ~~~bash export RAIS_WEBHOOK_SECRET="whsec_..." ~~~ Two things to know while you develop: - Raïs delivers from the internet, so your endpoint must be reachable from it. For a server on your computer, use a tunnel (ngrok, Cloudflare Tunnel and similar). Addresses such as `localhost` and private networks are refused. - A sandbox endpoint may use `http://`. A live endpoint must use `https://`. The **Send test event** button of the dashboard sends a fake `payment.succeeded` event to your endpoint, which is the quickest way to check that it answers. ## 7. Verify and confirm Every webhook carries a `Rais-Signature` header. Verify it against the **raw body** before you trust anything in the request, then answer with a `2xx` status. When the payer comes back to your site, do not trust the address either: retrieve the payment and check its status. The server below does all of it: it creates the payment and redirects, asks the API for the truth on the return page, and verifies the signature of the webhook. **A complete server: create a payment, redirect, receive the webhook** **Node.js** ```javascript import express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; const API = "https://api.rais.app/v1"; const SECRET_KEY = process.env.RAIS_SECRET_KEY; // rais_sk_test_... const WEBHOOK_SECRET = process.env.RAIS_WEBHOOK_SECRET; // whsec_... /** * rawBody: the request body exactly as received (Buffer or string), never re-serialised JSON. * header: the value of the Rais-Signature header. */ export function verifySignature(rawBody, header, secret, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) { const pairs = header.split(",").map((part) => part.trim().split("=")); const timestamp = pairs.find(([key]) => key === "t")?.[1]; const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value); if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false; if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); return signatures.some((signature) => { const received = Buffer.from(signature, "hex"); return received.length === expected.length && timingSafeEqual(received, expected); }); } async function rais(path, init = {}) { const response = await fetch(`${API}${path}`, { ...init, headers: { Authorization: `Bearer ${SECRET_KEY}`, "Content-Type": "application/json", ...init.headers }, }); return { ok: response.ok, body: await response.json() }; } const app = express(); const orders = new Map(); // order id -> { paymentId, amount }. Use your database in production. const processed = new Set(); // ids of the events already handled. Use your database too. app.get("/", (req, res) => { res.send('
'); }); // 1. Create the payment on your server, keep its id with the order, then send the payer to the hosted checkout. app.post("/checkout", async (req, res) => { const orderId = "1042"; // your own order const amount = 500000; // 5,000.00 DA, in centimes const { ok, body: payment } = await rais("/payments", { method: "POST", headers: { "Idempotency-Key": `order-${orderId}` }, // one payment per order, even if this handler runs twice body: JSON.stringify({ amount, description: `Order #${orderId}`, reference: `order-${orderId}`, success_url: "http://localhost:3000/thanks", // https:// is required for live payments cancel_url: "http://localhost:3000/", }), }); if (!ok) return res.status(502).json(payment); orders.set(orderId, { paymentId: payment.id, amount }); res.redirect(303, payment.checkout_url); }); // 2. The payer comes back here. The address proves nothing: ask the API about the payment YOU created for this order. app.get("/thanks", async (req, res) => { const order = orders.get(String(req.query.reference ?? "").replace(/^order-/, "")); if (!order) return res.status(404).send("Unknown order."); const { ok, body: payment } = await rais(`/payments/${order.paymentId}`); const paid = ok && payment.status === "paid" && payment.amount === order.amount; res.send(paid ? "Thank you, we received your payment." : "Payment not completed."); }); // 3. Raïs calls this URL for every event. express.raw keeps the body exactly as it was signed. app.post("/webhooks/rais", express.raw({ type: "application/json" }), (req, res) => { if (!verifySignature(req.body, req.get("Rais-Signature") ?? "", WEBHOOK_SECRET)) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); if (event.id.startsWith("evt_test_")) return res.sendStatus(200); // the dashboard's test event: never fulfil an order for it if (processed.has(event.id)) return res.sendStatus(200); // delivered twice: nothing to do processed.add(event.id); if (event.type === "payment.succeeded") { const payment = event.data.object; const orderId = [...orders].find(([, order]) => order.paymentId === payment.id)?.[0]; if (orderId && payment.amount === orders.get(orderId).amount) console.log(`Order ${orderId} is paid (${payment.amount} centimes)`); } res.sendStatus(200); // answer quickly, do slow work afterwards }); app.listen(3000, () => console.log("Listening on http://localhost:3000")); ``` **Python** ```python import os import requests from flask import Flask, abort, redirect, request import hashlib import hmac import time API = "https://api.rais.app/v1" SECRET_KEY = os.environ["RAIS_SECRET_KEY"] # rais_sk_test_... WEBHOOK_SECRET = os.environ["RAIS_WEBHOOK_SECRET"] # whsec_... AUTH = {"Authorization": f"Bearer {SECRET_KEY}"} app = Flask(__name__) orders = {} # order id -> {"payment_id": ..., "amount": ...}. Use your database in production. processed = set() # ids of the events already handled. Use your database too. def verify_signature(raw_body, header, secret, tolerance=300, now=None): """raw_body: the request body exactly as received (bytes). header: the Rais-Signature header.""" timestamp, signatures = None, [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": timestamp = value elif key == "v1": signatures.append(value) if timestamp is None or not timestamp.isascii() or not timestamp.isdigit() or not signatures: return False if abs((time.time() if now is None else now) - int(timestamp)) > tolerance: return False signed = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode() return any(hmac.compare_digest(expected, signature.lower().encode()) for signature in signatures) @app.get("/") def home(): return '
' # 1. Create the payment on your server, keep its id with the order, then send the payer to the hosted checkout. @app.post("/checkout") def checkout(): order_id = "1042" # your own order amount = 500000 # 5,000.00 DA, in centimes response = requests.post( f"{API}/payments", headers={**AUTH, "Idempotency-Key": f"order-{order_id}"}, # one payment per order json={ "amount": amount, "description": f"Order #{order_id}", "reference": f"order-{order_id}", "success_url": "http://localhost:3000/thanks", # https:// is required for live payments "cancel_url": "http://localhost:3000/", }, timeout=30, ) if not response.ok: abort(502) payment = response.json() orders[order_id] = {"payment_id": payment["id"], "amount": amount} return redirect(payment["checkout_url"], code=303) # 2. The payer comes back here. The address proves nothing: ask the API about the payment YOU created for this order. @app.get("/thanks") def thanks(): order = orders.get(request.args.get("reference", "").removeprefix("order-")) if order is None: abort(404) payment = requests.get(f"{API}/payments/{order['payment_id']}", headers=AUTH, timeout=30).json() paid = payment.get("status") == "paid" and payment.get("amount") == order["amount"] return "Thank you, we received your payment." if paid else "Payment not completed." # 3. Raïs calls this URL for every event. get_data() returns the body exactly as it was signed. @app.post("/webhooks/rais") def webhook(): raw_body = request.get_data() if not verify_signature(raw_body, request.headers.get("Rais-Signature", ""), WEBHOOK_SECRET): abort(400) event = request.get_json(force=True) if event["id"].startswith("evt_test_"): return "", 200 # the dashboard's test event: never fulfil an order for it if event["id"] in processed: return "", 200 # delivered twice: nothing to do processed.add(event["id"]) if event["type"] == "payment.succeeded": payment = event["data"]["object"] order_id = next((key for key, order in orders.items() if order["payment_id"] == payment["id"]), None) if order_id and payment["amount"] == orders[order_id]["amount"]: print(f"Order {order_id} is paid ({payment['amount']} centimes)", flush=True) return "", 200 # answer quickly, do slow work afterwards if __name__ == "__main__": app.run(port=3000) ``` **PHP** ```php $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); $valid = false; foreach ($signatures as $signature) { $valid = hash_equals($expected, strtolower($signature)) || $valid; } return $valid; } function rais(string $method, string $path, ?array $body = null, array $extraHeaders = []): array { global $secretKey; $ch = curl_init(API . $path); $options = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => array_merge(['Authorization: Bearer ' . $secretKey, 'Content-Type: application/json'], $extraHeaders), ]; if ($body !== null) { $options[CURLOPT_POSTFIELDS] = json_encode($body); } curl_setopt_array($ch, $options); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); return [$status, json_decode((string) $response, true) ?? []]; } // order id => ['payment_id' => ..., 'amount' => ...] function orders(): array { return is_file(ORDERS_FILE) ? (json_decode(file_get_contents(ORDERS_FILE), true) ?? []) : []; } function saveOrder(string $orderId, array $order): void { file_put_contents(ORDERS_FILE, json_encode([$orderId => $order] + orders()), LOCK_EX); } $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $method = $_SERVER['REQUEST_METHOD']; if ($path === '/' && $method === 'GET') { echo '
'; } elseif ($path === '/checkout' && $method === 'POST') { // 1. Create the payment on your server, keep its id with the order, then send the payer to the hosted checkout. $orderId = '1042'; // your own order $amount = 500000; // 5,000.00 DA, in centimes [$status, $payment] = rais('POST', '/payments', [ 'amount' => $amount, 'description' => "Order #$orderId", 'reference' => "order-$orderId", 'success_url' => 'http://localhost:3000/thanks', // https:// is required for live payments 'cancel_url' => 'http://localhost:3000/', ], ["Idempotency-Key: order-$orderId"]); // one payment per order if ($status !== 201) { http_response_code(502); exit('Could not create the payment'); } saveOrder($orderId, ['payment_id' => $payment['id'], 'amount' => $amount]); header('Location: ' . $payment['checkout_url'], true, 303); } elseif ($path === '/thanks' && $method === 'GET') { // 2. The payer comes back here. The address proves nothing: ask the API about the payment YOU created for this order. $order = orders()[preg_replace('/^order-/', '', $_GET['reference'] ?? '')] ?? null; if ($order === null) { http_response_code(404); exit('Unknown order.'); } [$status, $payment] = rais('GET', '/payments/' . rawurlencode($order['payment_id'])); $paid = $status === 200 && ($payment['status'] ?? '') === 'paid' && ($payment['amount'] ?? 0) === $order['amount']; echo $paid ? 'Thank you, we received your payment.' : 'Payment not completed.'; } elseif ($path === '/webhooks/rais' && $method === 'POST') { // 3. Raïs calls this URL for every event. php://input is the body exactly as it was signed. $rawBody = file_get_contents('php://input'); if (!verifySignature($rawBody, $_SERVER['HTTP_RAIS_SIGNATURE'] ?? '', $webhookSecret)) { http_response_code(400); exit; } $event = json_decode($rawBody, true); if (str_starts_with($event['id'], 'evt_test_')) { http_response_code(200); // the dashboard's test event: never fulfil an order for it exit; } // Delivered twice? Keep $event['id'] in your database and skip the ones you have seen. if ($event['type'] === 'payment.succeeded') { $payment = $event['data']['object']; foreach (orders() as $orderId => $order) { if ($order['payment_id'] === $payment['id'] && $order['amount'] === $payment['amount']) { error_log("Order $orderId is paid ({$payment['amount']} centimes)"); } } } http_response_code(200); // answer quickly, do slow work afterwards } else { http_response_code(404); } ``` **Go** ```go package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "math" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" ) type order struct { PaymentID string Amount int } var ( api = "https://api.rais.app/v1" secretKey = os.Getenv("RAIS_SECRET_KEY") // rais_sk_test_... webhookSecret = os.Getenv("RAIS_WEBHOOK_SECRET") // whsec_... orders sync.Map // order id -> order. Use your database in production. processed sync.Map // ids of the events already handled. Use your database too. ) // VerifySignature checks a Rais-Signature header against the raw request body. func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration, now time.Time) bool { var timestamp string var signatures []string for _, part := range strings.Split(header, ",") { key, value, _ := strings.Cut(strings.TrimSpace(part), "=") switch key { case "t": timestamp = value case "v1": signatures = append(signatures, value) } } if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" || len(signatures) == 0 { return false } seconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || math.Abs(float64(now.Unix()-seconds)) > tolerance.Seconds() { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := mac.Sum(nil) valid := false for _, signature := range signatures { if received, err := hex.DecodeString(signature); err == nil && hmac.Equal(received, expected) { valid = true } } return valid } func rais(method, path string, body any, headers map[string]string) (int, map[string]any, error) { var reader io.Reader if body != nil { encoded, err := json.Marshal(body) if err != nil { return 0, nil, err } reader = bytes.NewReader(encoded) } req, err := http.NewRequest(method, api+path, reader) if err != nil { return 0, nil, err } req.Header.Set("Authorization", "Bearer "+secretKey) req.Header.Set("Content-Type", "application/json") for name, value := range headers { req.Header.Set(name, value) } res, err := http.DefaultClient.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() var decoded map[string]any _ = json.NewDecoder(res.Body).Decode(&decoded) return res.StatusCode, decoded, nil } func main() { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `
`) }) // 1. Create the payment on your server, keep its id with the order, then send the payer to the hosted checkout. mux.HandleFunc("POST /checkout", func(w http.ResponseWriter, r *http.Request) { orderID := "1042" // your own order amount := 500000 // 5,000.00 DA, in centimes status, payment, err := rais("POST", "/payments", map[string]any{ "amount": amount, "description": "Order #" + orderID, "reference": "order-" + orderID, "success_url": "http://localhost:3000/thanks", // https:// is required for live payments "cancel_url": "http://localhost:3000/", }, map[string]string{"Idempotency-Key": "order-" + orderID}) // one payment per order if err != nil || status != http.StatusCreated { http.Error(w, "could not create the payment", http.StatusBadGateway) return } orders.Store(orderID, order{PaymentID: payment["id"].(string), Amount: amount}) http.Redirect(w, r, payment["checkout_url"].(string), http.StatusSeeOther) }) // 2. The payer comes back here. The address proves nothing: ask the API about the payment YOU created for this order. mux.HandleFunc("GET /thanks", func(w http.ResponseWriter, r *http.Request) { value, found := orders.Load(strings.TrimPrefix(r.URL.Query().Get("reference"), "order-")) if !found { http.NotFound(w, r) return } known := value.(order) status, payment, err := rais("GET", "/payments/"+url.PathEscape(known.PaymentID), nil, nil) if err == nil && status == http.StatusOK && payment["status"] == "paid" && payment["amount"] == float64(known.Amount) { fmt.Fprint(w, "Thank you, we received your payment.") return } fmt.Fprint(w, "Payment not completed.") }) // 3. Raïs calls this URL for every event. ReadAll returns the body exactly as it was signed. mux.HandleFunc("POST /webhooks/rais", func(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil || !VerifySignature(rawBody, r.Header.Get("Rais-Signature"), webhookSecret, 300*time.Second, time.Now()) { w.WriteHeader(http.StatusBadRequest) return } var event struct { ID string `json:"id"` Type string `json:"type"` Data struct { Object struct { ID string `json:"id"` Amount int `json:"amount"` } `json:"object"` } `json:"data"` } if err := json.Unmarshal(rawBody, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return } if strings.HasPrefix(event.ID, "evt_test_") { w.WriteHeader(http.StatusOK) // the dashboard's test event: never fulfil an order for it return } if _, seen := processed.LoadOrStore(event.ID, true); seen { w.WriteHeader(http.StatusOK) // delivered twice: nothing to do return } if event.Type == "payment.succeeded" { orders.Range(func(orderID, value any) bool { if known := value.(order); known.PaymentID == event.Data.Object.ID && known.Amount == event.Data.Object.Amount { log.Printf("Order %s is paid (%d centimes)", orderID, known.Amount) } return true }) } w.WriteHeader(http.StatusOK) // answer quickly, do slow work afterwards }) log.Println("Listening on http://localhost:3000") log.Fatal(http.ListenAndServe(":3000", mux)) } ``` Install the dependencies, set the two variables above and run it: **Node.js** ~~~bash npm install express node server.mjs ~~~ **Python** ~~~bash pip install flask requests python app.py ~~~ **PHP** ~~~bash php -S localhost:3000 index.php ~~~ **Go** ~~~bash go run main.go ~~~ Open `http://localhost:3000`, press the button, pay with the test card, and watch your terminal print the paid order. ## Next steps - [Webhooks](https://rais.app/docs/webhooks.md): events, retries, secret rotation and the verification code in every language. - [Payments](https://rais.app/docs/payments.md): statuses, metadata, items, expiry and cancellation. - [Going live](https://rais.app/docs/going-live.md): business verification and the checklist before real payments. --- # Authentication > Secret API keys, the test and live environments, scopes, rotation and IP allowlists. Source: https://rais.app/docs/authentication Every request to the API carries a **secret key** as a bearer token. A key belongs to one store and one environment, can be limited to some operations, and can be restricted to the addresses your servers use. ## Send the key **Request** ```bash curl https://api.rais.app/v1/account \ -H "Authorization: Bearer rais_sk_test_..." ``` The header is `Authorization: Bearer `. There are no other credentials: no client id, no signature, no session. If the key is missing or wrong, the answer is `401` with the code `invalid_api_key`. ## Key format | Key | Environment | | --- | --- | | `rais_sk_test_` followed by 43 characters | Sandbox | | `rais_sk_live_` followed by 43 characters | Live | Raïs stores only a one-way hash of your key, so it cannot show the key again after you create it. Copy it into your secret manager when it appears. If you lose it, create a new one and revoke the old one. The dashboard lists keys by their prefix and last four characters. ## Test and live The environment of a request is decided by the key and nothing else: there is no header or parameter to switch. | | Sandbox key | Live key | | --- | --- | --- | | Payments | Test payments, `livemode: false` | Real payments, `livemode: true` | | Cards | SATIM test cards only | Real CIB and Edahabia cards | | Money | Never moves, no balance changes | Credited to your balance | | Availability | Always | After business verification: the dashboard creates no live key before | Sandbox and live data are separate worlds. A sandbox key never sees a live payment and the other way round: asking for one answers `404`. ## Scopes A key carries one or more scopes. A request that needs a scope the key lacks answers `403` with `insufficient_scope`. New keys get every scope; give a key only what its job needs. | Scope | Allows | | --- | --- | | `payments:read` | Retrieve and list payments and refunds, and fetch receipts | | `payments:write` | Create and cancel payments | | `refunds:write` | Refund payments | | `events:read` | Retrieve and list events | `GET /account` works with any valid key, whatever its scopes, which makes it the right call for checking a key. ## Create, roll and revoke keys In the dashboard, open **Developers**, then **API keys**. - **Create.** Choose the store and the environment (**Test** or **Live**; **Live** is offered for a store approved for live payments only), a name and the scopes, and optionally an IP allowlist. The secret is shown once. A key has no expiry date: it works until you roll or revoke it. - **Roll.** Replace a key without downtime: the new key has the same store, environment, scopes and allowlist. The old key stops working at once, or after a grace period of **1 hour, 24 hours or 7 days** that gives you time to deploy the new key first; the dashboard shows until when the old key is valid, and after that it is expired and answers `401 invalid_api_key`. The new secret is shown once, like any new key. - **Revoke.** The key stops working for good. A revoked key is refused on the server that handles the revocation immediately and everywhere else within 10 seconds. A store can have up to 50 keys in each environment: revoke the ones you no longer use. > [!TIP] > **Roll keys on a schedule** > > Rolling with a grace period needs no maintenance window: deploy the new key, check your logs for the old key's prefix going quiet, then let the grace period end. ## IP allowlist A key can be limited to up to 50 IP addresses or CIDR ranges, IPv4 or IPv6, for example `203.0.113.7` or `198.51.100.0/24`. A request from any other address is refused exactly like an unknown key: the answer does not say why. Use the public address your server connects from, which behind a NAT gateway is the gateway's address. ## One key, one store A key can only read and change the payments, refunds and events of **its own store** in **its own environment**. > [!NOTE] > **What a 404 means** > > `payment_not_found`, `refund_not_found` and `event_not_found` are returned when the object does not exist, belongs to another store, or belongs to the other environment. The three cases are deliberately indistinguishable, so a key cannot be used to find out what exists elsewhere. Refunds follow the same rule: a key can refund only payments of its own store and environment. If you run several stores, give each one its own keys. ## Authentication errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | The key is missing, malformed, unknown, revoked or expired; it is used from an address outside its allowlist; or its store is suspended, disabled or, for a live key, not approved for live payments. The answer is the same in every case, so that a key cannot be used to learn anything. | | 403 | `insufficient_scope` | The key lacks the scope this endpoint needs. | | 403 | `account_disabled`, `live_mode_not_enabled` | Listed in the OpenAPI document, but the key check answers `401 invalid_api_key` before they can happen. Handle a `403` like any other refusal and contact support if it persists. See [Going live](https://rais.app/docs/going-live.md). | | 429 | `rate_limit_exceeded` | Too many requests from this key, or more than 30 failed authentications in a minute from one address. While an address is throttled for failed authentications, every request from it answers `429`, even with a valid key. Wait for `Retry-After`. | A `401` carries a `WWW-Authenticate: Bearer realm="Rais API"` header. See [Errors](https://rais.app/docs/errors.md) for the envelope. ## Keeping keys safe - Keep keys on your server, in environment variables or a secret manager. Never in a web page, a mobile app, a repository, a log or a support ticket. - The API sends no CORS headers: a browser cannot call it with a key, by design. Give your front end the `checkout_url` of a payment instead. - Use a separate key for each service, with only the scopes it needs, and an allowlist when your servers have fixed addresses. - If a key may have leaked, roll or revoke it first and investigate afterwards. See [Security and safety](https://rais.app/docs/security.md). --- # Payments > Create, retrieve, list and cancel payments: amounts, statuses, metadata, items and expiry. Source: https://rais.app/docs/payments A **payment** is one attempt to collect an amount from a payer. You create it on your server, redirect the payer to its hosted checkout, and follow it until it ends. This guide covers everything you can set and read; the exact schema of every endpoint is in the [API reference](https://rais.app/docs/api.md). ## Create a payment **Request** ```bash curl https://api.rais.app/v1/payments \ -H "Authorization: Bearer rais_sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b7e1c1c6-6f0a-4d2f-9d8b-3a5c2f1e7a90" \ -d '{ "amount": 500000, "description": "Order #1042", "reference": "order-1042", "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart" }' ``` **Response** (`201`) ~~~json { "id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "object": "payment", "status": "pending", "amount": 500000, "currency": "DZD", "description": "Order #1042", "reference": "order-1042", "customer": { "id": null, "name": "Amina Benali", "email": "amina@example.dz", "phone": "+213550000000" }, "items": [ { "name": "Running shoes", "description": null, "quantity": 2, "unit_amount": 250000 } ], "metadata": { "cart_id": "c_8812" }, "livemode": false, "payment_page": "rais", "checkout_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "expires_at": 1793003600, "created": 1793000000, "paid_at": null, "amount_refunded": 0, "refunded": false, "fees": null, "net": null, "receipt_url": null, "failure": null, "payment_method": null, "processor": { "name": "satim", "order_id": "V721uPPfNNofVQAAABL3", "approval_code": null, "response_code": null } } ~~~ Redirect the payer to `checkout_url`. The payment is `pending` until they pay. The payer's page lives on `https://pay.rais.app` and the API on `https://api.rais.app`: you call the API from your server, and only ever send the payer to the page. ## Amounts Amounts are **integers in centimes**: 1 DA is 100 centimes, so `500000` is 5,000.00 DA. A float (`5000.5`) or a string (`"5000"`) is refused with `invalid_amount`. - The smallest payment is 5000 centimes (50.00 DA), SATIM's minimum. A smaller amount, zero and negative numbers included, answers `amount_too_small`. - The largest is 999999999999 centimes. A larger amount answers `amount_too_large`. - The only currency is `DZD`, which is also the default. Any other value answers `currency_not_supported`. Always compute the amount on your server from your own prices. An amount that comes from the browser can be changed by the payer. ## Parameters - `amount` (integer, required unless `items` is given): the amount in centimes. When you send both, they must agree, or the answer is `amount_mismatch`. - `currency` (string, optional): `DZD`. - `description` (string, optional, at most 512 characters): what the payment is for. The payer sees it on the checkout and SATIM receives it. - `reference` (string, optional, at most 64 characters): your own identifier of the order. Returned as is, and searchable with `GET /payments?reference=`. - `customer` (object, optional): who is paying. Once the payment is paid, Raïs links it to a customer record made from these details and fills in `customer.id`; it is `null` while the payment is pending. A card is kept under that record only if the payer chooses to save it: see [Saved cards](https://rais.app/docs/security.md#saved-cards). - `name` (string, at most 255 characters): the payer's full name. - `email` (string, at most 255 characters): their e-mail address. A malformed address answers `invalid_email`. - `phone` (string, at most 50 characters): their phone number. - `customer_id` (string, optional): the id of one of **your** customers, one of the store the key belongs to (any other id answers `customer_not_found`). The customer's page in the dashboard shows it, and a paid payment returns it as `customer.id`. It fills in the payer's name and e-mail. On the Raïs payment page it also lets the payer choose to save their card for next time, and pay with a card they saved earlier: they pick it and confirm with 3-D Secure (the card and its security code are stored encrypted, for your store only). Saved cards are kept per mode: a sandbox payment never offers a live card, nor a live payment a sandbox one. - `items` (array, optional, at most 50 lines): what is being bought, shown on the receipt. - `name` (string, required): what is being bought. - `description` (string, optional): more detail. - `quantity` (integer, required): 1 or more. - `unit_amount` (integer, required): the price of one unit, in centimes. - `success_url` (string, optional): where the payer goes after paying. See [Hosted checkout and return URLs](https://rais.app/docs/checkout.md). - `cancel_url` (string, optional): where the payer goes if they cancel or the payment fails. - `metadata` (object, optional): up to 20 pairs of your own. Keys are at most 40 characters; values must be strings of at most 500 characters. - `payment_page` (string, optional): `rais` for Raïs' hosted checkout or `satim` for SATIM's own page. Defaults to your store's setting. - `language` (string, optional): `en`, `fr` or `ar`, the language of the checkout and of the receipt. - `expires_in` (integer, optional): seconds the payment stays payable, from 300 to 2592000 (30 days). Defaults to 86400 (24 hours). Any field of the JSON body that the API does not know is refused with `parameter_unknown`, which catches typos such as `ammount`. Unknown **query** parameters are ignored instead: `GET /payments?stauts=paid` returns every payment, so check the spelling of your filters. ### Items When you describe the order with `items`, the amount can be left out: it is the sum of `quantity` times `unit_amount` over the lines. Items appear on the receipt, so give each line a clear name. **Request** ```bash curl https://api.rais.app/v1/payments \ -H "Authorization: Bearer rais_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "items": [ { "name": "Running shoes", "quantity": 2, "unit_amount": 250000 }, { "name": "Delivery", "quantity": 1, "unit_amount": 80000 } ], "success_url": "myapp://pay/result", "language": "fr" }' ``` The payment above is for 580,000 centimes, that is 5,800.00 DA. ### Reference and metadata Use `reference` for the identifier you search by, and `metadata` for anything else you want back with the payment, such as a cart id. Both come back on the payment, in `GET` requests and in events. Do not put secrets in either of them: `reference` is also added to the address the payer is sent back to. ## Statuses | Status | Meaning | | --- | --- | | `pending` | Created and waiting for the payer. The payer may try again on the same link until `expires_at`. This is the only status in which a payment can be paid or cancelled. | | `paid` | The money was received. `paid_at`, `fees`, `net`, `payment_method` and `receipt_url` are set. | | `failed` | The bank declined the card, or the payment ended in an error. `failure` has a `code` (`payment_declined`, `processing_error` or `gateway_unavailable`) and a `message` for you, not for the payer. | | `cancelled` | You cancelled it while it was pending. | | `expired` | Nobody paid before `expires_at`. No event is sent when a payment expires. | | `partially_refunded` | Part of the amount was returned. `amount_refunded` says how much. | | `refunded` | All of the amount was returned. | | `reversed` | SATIM reversed the authorisation after the payment. | `pending` leads to `paid`, `failed`, `cancelled` or `expired`. A `paid` payment can become `partially_refunded`, then `refunded`, or `reversed`. Money states only move forward. A payment that ended `failed` because the bank declined the card, `cancelled` or `expired` cannot be paid later: create a new one. One rare exception: a payment that ended `failed` with `failure.code` `processing_error` can still turn `paid`, when the gateway answered with an error at the moment the payer came back although the card had been charged. Raïs keeps asking the gateway about such payments (see below), and when it confirms the charge you receive `payment.succeeded`. Treat `payment.succeeded` as the final word even if you saw `payment.failed` first. A payment is `expired` as soon as `expires_at` has passed without payment, even before anything else touches it. Nothing is sent when that happens, so do not wait for an event: when the order matters, compare `expires_at` with the clock, or retrieve the payment. A payment created in the dashboard with the expiry "Never" has `expires_at: null` and is payable until you cancel it. ### A paid payment When a payment is `paid`, it looks like this (the same object a `payment.succeeded` event carries): ~~~json { "id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "object": "payment", "status": "paid", "amount": 500000, "currency": "DZD", "description": "Order #1042", "reference": "order-1042", "customer": { "id": "910158b6-8677-4d31-8779-919c4ea6428a", "name": "Amina Benali", "email": "amina@example.dz", "phone": "+213550000000" }, "items": [ { "name": "Running shoes", "description": null, "quantity": 2, "unit_amount": 250000 } ], "metadata": { "cart_id": "c_8812" }, "livemode": false, "payment_page": "rais", "checkout_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "expires_at": 1793003600, "created": 1793000000, "paid_at": 1793000300, "amount_refunded": 0, "refunded": false, "fees": { "satim": 3500, "rais": 1500, "total": 5000 }, "net": 495000, "receipt_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33/receipt.pdf", "failure": null, "payment_method": { "type": "card", "brand": "dahabia", "last4": "7215", "masked": "628058**7215" }, "processor": { "name": "satim", "order_id": "V721uPPfNNofVQAAABL3", "approval_code": "913180", "response_code": "00" } } ~~~ `payment_method` shows the card scheme (`cib`, `dahabia` or `unknown`), the last four digits and a masked number, never more than the first six and last four digits. `processor` holds what SATIM knows: its order id, the approval code and the response code (`00` means approved). ### What a payment costs A paid payment carries what it cost you and what you receive: - `fees` has the `satim` part, the `rais` part and their `total`, in centimes, **excluding VAT**. It is `null` until the payment is `paid`: a payment that is pending, failed, expired or cancelled costs nothing. - `net` is `amount` minus `fees.total`, before any refund: what you receive for this payment. In the example above, 500000 − 5000 = 495000 centimes (4,950.00 DA). - The fees are set once, when the payment is paid, and are **not returned** when it is refunded, in full or in part: each refund has its own fee (see [Refunds](https://rais.app/docs/refunds.md)). - A sandbox payment shows the **simulated** fees it would have cost in live: nothing is charged and no balance changes. The example above is a sandbox payment. - A payment paid before fees were introduced has `fees: null` and `net: null`: nothing is charged afterwards for it. The price list, when a fee is charged and worked examples are in [Pricing and fees](https://rais.app/docs/pricing.md). ### What the payer can do before it ends While a payment is `pending`, the payer can leave the page, come back to the same `checkout_url` and try again, until `expires_at`. Closing the tab, pressing **Back to**, a mistyped card number or a SATIM form error do not change the status. The result page the payer lands on after an unsuccessful attempt offers a **Try again** button for as long as the payment can still be paid. A card refused by the bank is different: the payment becomes `failed` at once and its link no longer takes a payment. ### When the payer pays and never comes back Some payers pay and close the tab before Raïs sees the result. Raïs does not leave such a payment `pending`: a background check asks the payment gateway about unpaid payments, the first time about 2 minutes after the gateway session was opened, then again after 5 minutes, 15 minutes, 1 hour, 6 hours and 24 hours (each wait counted from the previous check), and stops 48 hours after the session was opened. When the gateway says the payment was paid, the payment becomes `paid` and `payment.succeeded` is sent, **without the payer ever returning to your site**. Reading the payment through the API also asks the gateway (at most every 15 seconds). So never rely on the browser redirect alone: confirm the payment with the webhook or with `GET /payments/{id}`. See [Hosted checkout and return URLs](https://rais.app/docs/checkout.md). ## Retrieve a payment **Request** ```bash curl https://api.rais.app/v1/payments/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33 \ -H "Authorization: Bearer rais_sk_test_..." ``` If the payment is still `pending`, Raïs asks the gateway for the outcome (at most every 15 seconds), so a payer who comes back from the checkout always sees the truth, even if their browser never reached Raïs. Retrieving a payment is the right way to confirm a result. A payment of another store or of the other environment answers `404`. ## List payments **Request** ```bash curl 'https://api.rais.app/v1/payments?status=paid&limit=3' \ -H "Authorization: Bearer rais_sk_test_..." ``` Payments come back newest first, 20 at a time by default. Filter by `status`, by your own `reference`, or by creation time with `created_gte` and `created_lte` (Unix seconds). `status=pending` leaves out the payments that are past `expires_at`: ask for `status=expired` to see those. Payment links created in the dashboard are payments like any other and are listed here too. See [Pagination](https://rais.app/docs/pagination.md). ## Cancel a payment Cancel a payment that is no longer needed so that its checkout cannot be used. **Request** ```bash curl -X POST https://api.rais.app/v1/payments/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33/cancel \ -H "Authorization: Bearer rais_sk_test_..." ``` Only a `pending` payment can be cancelled. Any other status answers `409` with `payment_not_cancellable`: a payment that is already `paid` has to be refunded instead. The same answer comes back while the payer is in the middle of a 3-D Secure step for that payment: try again in a moment. A successful cancellation emits a `payment.cancelled` event. ## Retrying safely Send an `Idempotency-Key` with every payment you create. If a request times out and you send it again with the same key, you get the original payment back and nothing is created twice. See [Idempotency and retries](https://rais.app/docs/idempotency.md). ## What to read next - [Hosted checkout and return URLs](https://rais.app/docs/checkout.md): the page your payer sees and where they go afterwards. - [Webhooks](https://rais.app/docs/webhooks.md): learn the outcome without polling. - [Refunds](https://rais.app/docs/refunds.md): return money for a paid payment. --- # Pricing and fees > What a payment and a refund cost, when a fee is charged, worked examples and the fee fields of the API. Source: https://rais.app/docs/pricing Raïs charges a fee on each **paid payment** and on each **refund**, and nothing else: no subscription, no setup fee, no fee on a payment that fails, and no fee on a withdrawal. Every figure on this page excludes VAT (HT, *hors taxes*). The public prices, with a calculator, are on the [pricing page](https://rais.app/pricing). ## The price list | | SATIM | Raïs | Total | | --- | --- | --- | --- | | A payment | 0.7% of the amount, never less than 10.00 DA and never more than 300.00 DA | 0.3% of the amount, with no minimum and no maximum, on top of SATIM's | Both added | | A refund | 8.00 DA | 2.00 DA | 10.00 DA, whatever the amount | A payment therefore costs about 1% of its amount for an ordinary amount: SATIM's 0.7% plus Raïs's 0.3%. SATIM's minimum makes the smallest payments cost more in proportion: the minimum payment, 50.00 DA, costs 10.15 DA. ## How a fee is worked out Fees are whole centimes, worked out with integers (never floating point): a percentage is a number of basis points, and the share of an amount is `(amount × basis points + 5000) ÷ 10000`, a half rounded up. SATIM's part is 70 basis points, kept between 10.00 DA and 300.00 DA; Raïs's is 30 basis points. A fee never exceeds the amount it is taken from: SATIM's part is taken first. ## Examples | Amount | SATIM | Raïs | Total fee | You receive | | --- | --- | --- | --- | --- | | 50.00 DA | 10.00 DA | 0.15 DA | 10.15 DA | 39.85 DA | | 1,000.00 DA | 10.00 DA | 3.00 DA | 13.00 DA | 987.00 DA | | 5,000.00 DA | 35.00 DA | 15.00 DA | 50.00 DA | 4,950.00 DA | | 10,000.00 DA | 70.00 DA | 30.00 DA | 100.00 DA | 9,900.00 DA | | 50,000.00 DA | 300.00 DA | 150.00 DA | 450.00 DA | 49,550.00 DA | | 100,000.00 DA | 300.00 DA | 300.00 DA | 600.00 DA | 99,400.00 DA | | 500,000.00 DA | 300.00 DA | 1,500.00 DA | 1,800.00 DA | 498,200.00 DA | At 1,000.00 DA SATIM's minimum applies: 0.7% would be 7.00 DA, so SATIM's part is 10.00 DA. At 50,000.00 DA its maximum applies: 0.7% would be 350.00 DA, so SATIM's part is 300.00 DA, while Raïs's part keeps growing with the amount. A refund costs 10.00 DA (8.00 DA for SATIM and 2.00 DA for Raïs) whether it refunds 1.00 DA or 50,000.00 DA. ## When a fee is charged | What happens | What it costs | | --- | --- | | A payment is **paid** | Its fees are charged, once, when it is paid. They are set on the payment (`fees`, `net`) and, for a live payment, taken from your balance as a separate line of your ledger. | | A payment **fails**, is declined, expires or is cancelled | Nothing. | | A payment is **refunded**, in full or in part | The fees of the payment are **not returned**. The refund has its own fee. | | A refund **succeeds** | Its fee, 10.00 DA, is charged. | | A refund **fails** | Nothing. A refund that does not go through costs nothing. | | A refund is **pending** (the gateway's answer was lost) | Its fee is reserved on your balance with its amount, and only charged if the refund goes through. | | A **withdrawal** to your bank account | Nothing. | ## Fees in the API A payment carries what it cost you and what you receive. `fees` is `null` until the payment is `paid`; `net` is `amount` minus `fees.total`, before any refund: ~~~json { "id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33", "amount": 500000, "fees": { "satim": 3500, "rais": 1500, "total": 5000 }, "net": 495000 } ~~~ A refund carries its own `fees` once it has `succeeded` (`null` while it is `pending` and when it `failed`): ~~~json { "id": "0c8a1d52-6e7b-4f0a-9b3d-7d2e4a8c5f10", "amount": 150000, "status": "succeeded", "fees": { "satim": 800, "rais": 200, "total": 1000 } } ~~~ | Field | Meaning | | --- | --- | | `fees.satim` | SATIM's part, in centimes, excluding VAT. | | `fees.rais` | Raïs's part, in centimes, excluding VAT. | | `fees.total` | `satim` + `rais`: what the payment or the refund costs. | | `net` | On a payment: `amount` minus `fees.total`. What you receive for it, before any refund. | The same objects travel in the events and the webhooks: a `payment.succeeded` event carries the payment with its `fees` and `net`, and a `refund.succeeded` event the refund with its `fees`. See [Webhooks](https://rais.app/docs/webhooks.md) and the [payment](https://rais.app/docs/api/objects/payment.md) and [refund](https://rais.app/docs/api/objects/refund.md) objects. What you keep from a payment that was refunded is its `net`, minus the amount refunded, minus the `fees.total` of each refund that succeeded. For the payment above refunded by 1,500.00 DA: 4,950.00 DA − 1,500.00 DA − 10.00 DA = 3,440.00 DA. ## Your balance and refunds A live payment is credited to your balance in full, and its fees are taken from the balance right after, so the balance moves by the payment's `net`. A live refund is taken from your balance with its fee: **the balance must cover the amount of the refund and its fee**, otherwise the answer is `insufficient_balance` ("The store's balance is not sufficient to cover this refund and its fee."). Because the fee of the payment is not returned, a store that has received a single payment can refund at most its balance minus 10.00 DA. See [Refunds](https://rais.app/docs/refunds.md#what-a-refund-costs). ## Test mode The sandbox shows the same figures, marked as **simulated**: a paid sandbox payment has its `fees` and `net`, and a succeeded sandbox refund has its `fees`, exactly as in live. Nothing is charged, no balance changes and no ledger line is written. See [Test cards and sandbox](https://rais.app/docs/testing.md). ## Payments from before fees A payment paid before fees were introduced has `fees: null` and `net: null`: nothing is charged afterwards for it, and the dashboard shows no fee for it. ## What is not included Every price excludes VAT: the amounts in `fees` are the HT prices above. The price list is versioned (`2026-10`): the fees of a payment are fixed when it is paid, with the price list of that day. --- # Hosted checkout and return URLs > Send the payer to the hosted page, bring them back to your site or app, and confirm the result on your server. Source: https://rais.app/docs/checkout When you create a payment, Raïs returns a `checkout_url`. That address is a hosted payment page: you never handle card details. This guide describes what the payer sees, where they go afterwards and how to confirm the result. ## Where the page lives The payer's pages live on `https://pay.rais.app`: `checkout_url` is `https://pay.rais.app/pay/{payment_id}`, and the `receipt_url` of a paid payment is `https://pay.rais.app/pay/{payment_id}/receipt.pdf`. The API lives on `https://api.rais.app` (every endpoint is under `https://api.rais.app/v1`): call it from your server, never from the payer's browser. Use the `checkout_url` you are given as it is, and do not build it yourself. If your site or your app restricts where it may redirect, or which hosts an in-app browser may open, allow `https://pay.rais.app`. ## The hosted page Redirect the payer to `checkout_url` with a normal HTTP redirect (status 303 after a POST) or a link. The page: - shows your store name and logo (upload the logo in the dashboard, under the store's **Settings**; a store without one shows its initial), the amount with commas between the thousands and a dot before the decimals (`5,000.00 DA`), the description and your reference; - is available in English, French and Arabic, with right-to-left layout for Arabic. Set the language with `language` when you create the payment; - collects the card number, expiry, CVC and name, then runs 3-D Secure through SATIM; - lets the payer leave through a **Back to** link that returns to your `cancel_url`; - marks a sandbox payment with a "Test mode" banner. It never lists test cards (they are in [Test cards and sandbox](https://rais.app/docs/testing.md)), and a live payment never shows any test wording. The page sets a strict content security policy and forbids being framed: it cannot be embedded in an `