Quickstart
This guide walks through one test payment: your server creates a payment page, you pay on it with a test card, and your server receives the payment.succeeded webhook and checks its signature. Everything uses a test key (sk_test_…), so no real money moves.
sk_test_ keys never reach a bank, and cards are not charged. Test and live mode use the same endpoints, the same payment page, the same webhook format and the same signature; test responses and events carry "livemode": false.1. Get a test key
You need a MAXFI merchant account. There is no self-service sign-up: send an application on the access request page. When your account is set up, open merchant portal → Developer Hub → API Keys and create a test key pair.
Keep the secret key on your server. Never put it in a web page or a mobile app.
# Test secret key: merchant portal → Developer Hub → API Keys export MAXFI_SECRET_KEY="sk_test_…"
2. Create a payment page
Your server sends one request to POST /v2/payments/checkout. amount is in minor units of the currency: 4900 is 49.00 USD.
curl https://maxfi-api.exezine.az/v2/payments/checkout \ -H "Authorization: Bearer $MAXFI_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 4900, "currency": "USD", "description": "Order 42", "order_id": "order_42", "success_url": "https://yourapp.com/orders/42/thanks", "cancel_url": "https://yourapp.com/orders/42", "webhook_url": "https://yourapp.com/maxfi/webhook" }'
The response contains checkout_url. Send the customer there: redirect them or show the link. The link is valid for 30 minutes (expires_at).
{
"id": "cs_4f0c9a2e7b1d4e8f9a6b3c5d7e9f1a2b",
"checkout_url": "https://maxfi-api.exezine.az/checkout/cs_4f0c9a2e7b1d4e8f9a6b3c5d7e9f1a2b",
"expires_at": "2026-11-13T09:45:00.000Z",
"amount": 4900,
"currency": "USD",
"status": "open",
"livemode": false
}If your server may repeat the request, for example after a timeout, send an Idempotency-Key header: a repeat with the same key and the same body returns the same session instead of creating a second one. Store the checkout_url you received and send the customer only to it.
3. Receive the webhook
When the payment succeeds or fails, MAXFI sends a POST request with a JSON event to the webhook_url from step 2: payment.succeeded or payment.failed. Without webhook_url in the request, events go to the webhook URL saved in the merchant portal. The address must be public and use https; MAXFI does not follow redirects. All event types: Webhooks.
The event has id, type, livemode, created_at and data; data.order_number repeats the order_id you sent.
If your account has a webhook secret, every delivery carries the X-MAXFI-Signature header: sha256= followed by the hex HMAC-SHA256 of the raw request body, computed with that secret. The secret is created when you save the webhook settings in merchant portal → Developer Hub → Webhooks, and it also signs deliveries to a webhook_url passed in the request. Without a secret, deliveries are not signed.
Reply with any 2xx status within 10 seconds. Otherwise MAXFI tries again: up to 8 attempts in total — right away, then 30 s, 2 min, 10 min, 30 min, 1 h, 6 h and 24 h after the previous attempt. The same event can arrive more than once, so process each event id only once.
import crypto from "node:crypto"; import express from "express"; const app = express(); // The signature covers the exact bytes MAXFI sent, so read the raw body. app.post("/maxfi/webhook", express.raw({ type: "application/json" }), (req, res) => { const received = req.get("X-MAXFI-Signature") || ""; const expected = "sha256=" + crypto .createHmac("sha256", process.env.MAXFI_WEBHOOK_SECRET) .update(req.body) .digest("hex"); const valid = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); if (!valid) return res.status(401).end(); const event = JSON.parse(req.body.toString("utf8")); if (alreadyProcessed(event.id)) return res.status(200).end(); switch (event.type) { case "payment.succeeded": markOrderPaid(event.data.order_number, event.livemode); break; case "payment.failed": markOrderFailed(event.data.order_number, event.data.error_message); break; } res.status(200).end(); });
4. Pay with a test card
Open checkout_url in a browser and pay with card 4242 4242 4242 4242, any future expiry date and any three-digit CVC. The payment succeeds, and payment.succeeded arrives at your webhook URL.
Each test card always behaves the same way. When the result comes right away in the response, your webhook URL also receives payment.succeeded or payment.failed, as for a live payment:
| Card number | Result |
|---|---|
| 4242 4242 4242 4242 | Visa, succeeds |
| 5555 5555 5555 4444 | Mastercard, succeeds |
| 3782 822463 10005 | American Express, succeeds |
| 4000 0000 0000 0002 | Declined: card_declined |
| 4000 0000 0000 9995 | Declined, insufficient funds: insufficient_funds |
| 4000 0000 0000 0069 | Declined, expired card: expired_card |
| 4000 0000 0000 0127 | Declined, incorrect CVC: incorrect_cvc |
| 4000 0000 0000 3220 | Requires 3-D Secure: you choose the result on the MAXFI test page |
| 4000 0000 0000 3063 | Requires 3-D Secure; the check fails whichever button you press: 3ds_failed |
| 4000 0000 0000 0119 | Processing error after 3 seconds: processing_error |
| 4000 0000 0000 9235 | Blocked as fraud: fraud_detected |
| 4000 0000 0000 0259 | Fails after 5 seconds (simulated network timeout) |
The same list is in merchant portal → Developer Hub → Sandbox.
Steps that a bank or a wallet would show the customer — the 3-D Secure check, confirmation in SberPay, T-Pay or YooMoney, payment by SBP QR code — open a MAXFI test page at maxfi-api.exezine.az/checkout/sandbox/confirm/… with two buttons: confirm or decline. Then you return to the payment page, which shows the result. For a payment created with POST /v2/payments you go to its return_url; without it the test page shows the result itself. Your choice is the result of the payment: it gets its final status, and your webhook URL receives payment.succeeded or payment.failed with "livemode": false, as for a live payment.
SberPay and T-Pay ask for a phone number. These numbers give fixed results (both methods work only for payments in RUB):
| Phone number | Result |
|---|---|
| +7 900 000 00 01 | The MAXFI test page opens: confirm or cancel the payment. Any number that is not in this table works the same way. |
| +7 900 000 00 02 | Declined, the customer cancelled: user_cancelled |
| +7 900 000 00 03 | Declined after 5 seconds, no confirmation in time: timeout |
| +7 900 000 00 04 | Blocked as fraud: fraud_detected |
| +7 900 000 00 05 | Declined, insufficient funds: insufficient_funds |
| +7 900 000 00 06 | Declined by the wallet: wallet_declined |
YooMoney and SBP always open the test page: for YooMoney the payment page asks only for an email, and the SBP QR code — and the link under it — leads to the same page. Through the API the same numbers work in payment_method_data.sberpay.phone and payment_method_data.tpay.phone, and for YooMoney in payment_method_data.yoomoney.wallet_id.
Apple Pay and Google Pay are not shown on the payment page in test mode: their buttons need your registration with Apple and Google. Test them through POST /v2/payments with payment_method set to apple_pay or google_pay and any non-empty payment_token: the test payment succeeds at once.
Going live
Live keys (sk_live_) work only after MAXFI enables live mode for your account, once onboarding and KYC are complete; until then, requests with a live key are refused. Then replace the test key with the live key on your server. Endpoints, request and response formats and webhooks are the same in both modes, but live payments move real money.
