# FahiPay Merchant API Accept payments with hosted checkout links and PayMV QR codes, or collect customer-approved payments for subscriptions, memberships, instalments, and usage-based services. **Base URL:** `https://api.fahipay.mv/merchants` This documentation covers API version **2027-07-01**. Compatible additions may be introduced within this version. Breaking changes will receive a new version. Integrations should ignore unrecognized response fields. The latest version is currently **2027-07-01**. The accompanying [openapi.json](openapi.json) contains the Merchant API definition, request and response schemas, and examples. A [combined OpenAPI definition](../openapi.json) is also available for the Merchant and Wallet APIs. ## Get started You need an active merchant account. Get your merchant ID and secret key from the [FahiPay merchant portal](https://fahipay.mv/merchants/portal/). Confirm your account's live or sandbox mode before integrating. Requests and resource lookups use your account's current mode; switching mode changes which payments and authorizations are available. Keep your secret key on your server. Customer-facing pages and apps should use the returned checkout, approval, and QR URLs without embedding merchant credentials. ### Import into Postman 1. Select **Import** in Postman and choose `openapi.json`. 2. Import it as a **Postman Collection**, or as a specification together with a collection. 3. Confirm Merchant API requests use `https://api.fahipay.mv/merchants`. 4. Set one authentication method for Merchant API requests, as described below. If setting authentication on a merchant folder, make sure its requests inherit it and remove any generated credential placeholders that would override it. 5. Start with `GET /payments`. Creation and charge requests create resources or collect payments in your account's current mode. The definition is self-contained; no additional schema files are needed. Postman supports OpenAPI 3.1 imports. See [Postman's OpenAPI integration guide](https://learning.postman.com/docs/integrations/available-integrations/working-with-openAPI/) and [import instructions](https://learning.postman.com/docs/getting-started/importing-and-exporting/importing-data). If you import the combined definition, Wallet requests use `https://api.fahipay.mv/wallet` and `X-API-Key` from [FahiPay Developers](https://fahipay.mv/dev/). Configure `WalletApiKey` for those requests. Wallet balances and usage are expressed in MVR; Merchant API monetary amounts are in integer laari. ### Authentication Choose the authentication method that suits your integration. API key and HTTP Basic authentication offer straightforward setup with full Merchant API access. Use a Bearer access token when an integration should have access only to selected operations. Always use HTTPS: | Method | Credentials | Postman configuration | | --- | --- | --- | | Bearer token | `Authorization: Bearer YOUR_ACCESS_TOKEN` | Bearer Token; enter the access token without the `Bearer` prefix. | | API key | `X-API-Key: YOUR_SECRET_KEY` | API Key; key `X-API-Key`, value your `secret_key`, add to Header. | | HTTP Basic | Username: your `merchant_id`; password: your `secret_key`. | Basic Auth with those credentials. | For API-key authentication, send only the secret key: no merchant ID or prefix is required. API key and Basic use the same case-sensitive secret key. Bearer authentication uses an OAuth access token. Send one method per request. Authentication precedence is Bearer, then `X-API-Key`, then Basic. A failed selected method returns HTTP 401 without falling back to another method. Keep both secret keys and access tokens on your server. ```sh curl 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' ``` The equivalent request using Basic authentication is: ```sh curl 'https://api.fahipay.mv/merchants/payments' \ --user 'YOUR_MERCHANT_ID:YOUR_SECRET_KEY' ``` ### Obtain an access token Use the Client Credentials flow with your OAuth client ID and client secret. The token endpoint is `https://api.fahipay.mv/merchants/oauth/token`. Send a form-encoded POST and request only the scopes your integration needs, separated by spaces: ```sh curl --request POST 'https://api.fahipay.mv/merchants/oauth/token' \ --user 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'scope=payments:read payments:create' ``` Use the returned `access_token` in the Bearer header on subsequent merchant requests. Keep the token and client secret on your server. In Postman, choose **OAuth 2.0**, select **Client Credentials**, set **Access Token URL** to `https://api.fahipay.mv/merchants/oauth/token`, enter your OAuth client credentials and required scopes, and choose **Send as Basic Auth header** for client authentication. If you already have an access token, choose **Bearer Token** and enter the token without the `Bearer` prefix. For a Bearer token granted `payments:read`: ```sh curl 'https://api.fahipay.mv/merchants/payments' \ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ### Endpoint scopes API key and HTTP Basic authentication grant all Merchant API scopes. Bearer access tokens grant only their assigned scopes. Each scope is independent: permission to create an authorization does not grant permission to charge it. Payment history under an authorization requires `payments:read`. Tokens with no recognized scopes cannot access authenticated endpoints. Scopes do not change merchant ownership checks or the account's current live or sandbox mode. The public QR endpoint requires no scope. | Scope | Endpoints | | --- | --- | | `payments:create` | `POST /payments` | | `payments:read` | `GET /payments`, `GET /payments/{id}`, `GET /authorizations/{id}/payments` | | `payments:cancel` | `POST /payments/{id}/cancel` | | `payment_requests:create` | `POST /payment-requests` | | `payment_requests:read` | `GET /payment-requests`, `GET /payment-requests/{id}` | | `payment_requests:cancel` | `POST /payment-requests/{id}/cancel` | | `authorizations:create` | `POST /authorizations` | | `authorizations:read` | `GET /authorizations`, `GET /authorizations/{id}` | | `authorizations:charge` | `POST /authorizations/{id}/charge` | | `authorizations:cancel` | `POST /authorizations/{id}/cancel` (cancel pending approval or revoke an active authorization) | A valid token without the required scope returns HTTP 403: ```json { "error": { "code": "insufficient_scope", "message": "Required scope: payments:create." } } ``` Use a token granted the required scope before retrying. Invalid or expired tokens return HTTP 401. ### API version header The `FahiPay-Version` request header is **optional**. Supply a supported date to select that release, or omit the header to use the **latest API version**. Pin a version when you want to control when your integration adopts a new release. ```sh curl 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'FahiPay-Version: 2027-07-01' ``` The server returns `FahiPay-Version` in response headers to identify the version used. An unsupported date or empty value returns HTTP 400 with error code `unsupported_api_version`; that error response identifies the latest supported version. This header applies to the Merchant API, including public QR requests. ## Request and response conventions FahiPay URLs in response examples contain descriptive placeholders such as `YOUR_PUBLIC_LINK_ID` and `YOUR_PAYMENT_ID`; these are not working links. Use the exact payment, QR, and approval URLs returned by the API; do not construct these URLs from resource IDs. Send JSON objects with `Content-Type: application/json` for creation and charge requests. GET requests and payment or authorization cancellation require no body. All monetary amounts and limits are **integer laari**. Only MVR is supported: **100 laari = MVR 1.00**. | MVR | JSON amount | | --- | --- | | 0.01 | `1` | | 20.00 | `2000` | | 123.45 | `12345` | Send `"amount": 2000`, with an integer JSON value. Numeric strings, decimals, and nested amount objects are rejected. Omit `currency` in requests; payments and authorizations always use MVR. Authorization responses include `currency: "MVR"`. Single-resource responses wrap the resource in `data`. Lists return a `data` array and a `pagination` object. IDs and references are strings; preserve them exactly, including leading zeroes in your references. Timestamps use RFC 3339 with a timezone offset. Supply explicit offsets for expiry values, for example `2026-12-31T23:59:59+05:00`. New expiry deadlines must be in the future. Responses normalize expiry values to +05:00 at second precision. A date or timestamp supplied without an offset is interpreted in +05:00. API responses carry `Cache-Control: no-store`. Fetch current payment or authorization status when making fulfilment or billing decisions. ## Hosted payments and PayMV QR Use a hosted payment for a checkout that the customer completes themselves. 1. Create a payment with your order reference and amount. 2. Save the returned payment `id` against your order. 3. Send the customer to `payment_url`, or display the PayMV QR image from `qr_url`. 4. Confirm `status: "paid"` through a verified webhook or a server-side payment lookup before fulfilling the order. A visit to `return_url` alone is not payment confirmation. ### Create a payment `POST /payments` #### Required fields only Only `reference` and `amount` are required in the body. This example requests MVR 20.00 and uses the default 24-hour expiry. The optional `Idempotency-Key` header uses the payment reference for safe retries. Reuse the same key and unchanged body when retrying; use a new reference and key for each new payment. ```sh curl 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'Idempotency-Key: ORDER-2026-001' \ -H 'Content-Type: application/json' \ --data '{ "reference": "ORDER-2026-001", "amount": 2000 }' ``` #### With optional fields Add checkout details, your own metadata, a return URL, and a custom expiry. Replace the example expiry with a future timestamp when testing. ```sh curl 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'Idempotency-Key: ORDER-2026-001' \ -H 'Content-Type: application/json' \ --data '{ "reference": "ORDER-2026-001", "amount": 2000, "title": "Order #001", "description": "Online order", "terminal_label": "COUNTER01", "bill_number": "BILL-2026-001", "return_url": "https://merchant.example/payment/complete", "expires_at": "2026-12-31T23:59:59+05:00", "metadata": { "membership_id": "MEM-123", "billing_period": "2026-10" } }' ``` | Field | Required | Meaning | | --- | --- | --- | | `reference` | Yes | Your nonblank payment reference, up to 100 characters after trimming. Must be unique across all payments for your merchant, including hosted payments and authorization charges. Reuse it only when retrying the same payment. | | `amount` | Yes | Integer from 1 to 9,999,999,999 laari. | | `title` | No | Payment title shown with the payment. | | `description` | No | Additional payment details. | | `metadata` | No | Merchant data as a JSON object: up to 50 string values, keys of 1–40 characters, values up to 500 characters. Omit or use `{}` for no metadata. | | `terminal_label` | No | Checkout counter or terminal label, up to 10 characters after trimming. For example, `COUNTER01`. Omit, use null, or send a blank string for no label. Labels may be reused across payments. | | `bill_number` | No | Your bill or invoice number, 1 to 25 characters after trimming when set. Allows `A-Z`, `a-z`, `0-9`, spaces, `-`, `_`, `.`, and `/`. Supply a string to preserve leading zeroes. Longer values or unsupported characters return HTTP 400. Omit, use null, or send a blank string for no bill number. | | `return_url` | No | Absolute HTTP or HTTPS URL, without embedded credentials, up to 2048 bytes after trimming. Omit, use null, or leave blank for none. Invalid values return HTTP 400 with `error.field: return_url`. A return URL visit is not payment confirmation. | | `expires_at` | No | Future expiry timestamp. Omit or use null for the default 24-hour expiry. | Title, description, and other payment text fields are trimmed. Optional text fields may be omitted or null. Metadata keys and values are preserved without trimming; metadata does not accept null, arrays, nested objects, numbers, or booleans. It is included in authenticated payment responses and payment webhooks, and hidden from public checkout. A retry with the same reference and amount returns the original metadata. Do not store secrets or sensitive payment information in metadata. Metadata can currently be supplied when creating hosted payments. Example: `"metadata": {"membership_id": "MEM-123", "billing_period": "2026-10"}`. A successful request returns HTTP 201: ```json { "data": { "id": "a1234567890b4cde8f0123456789abcd", "reference": "ORDER-2026-001", "amount": 2000, "fee": null, "net_amount": null, "title": "Order #001", "description": "Online order", "terminal_label": "COUNTER01", "bill_number": "BILL-2026-001", "status": "pending", "payment_reference": null, "payment_url": "https://fahipay.mv/p/YOUR_PUBLIC_LINK_ID", "qr_url": "https://api.fahipay.mv/merchants/public/qr/YOUR_PAYMENT_ID", "return_url": "https://merchant.example/payment/complete", "created_at": "2026-01-01T12:00:00+05:00", "paid_at": null, "expires_at": "2026-01-02T12:00:00+05:00" } } ``` ### Handle checkout results To return customers to your app, set `return_url` to a page on your own website, such as `https://merchant.example/payment/complete`. That page can redirect the customer to your app using your own app link. FahiPay accepts an HTTP or HTTPS return URL; do not supply a custom app scheme directly. Use HTTPS for your return page. 1. Save the payment `id` returned by FahiPay against your order before opening `payment_url`. 2. Use your own session or an opaque order token in the return URL to identify the order. Do not rely on FahiPay adding a payment ID or status to the URL. 3. When the customer returns to your website or app, have your backend retrieve the saved payment using `GET /payments/{id}`. Keep merchant credentials on your server. 4. Show success only for `paid`. Show `pending` as awaiting payment; handle `rejected`, `expired`, `cancelled`, and `refunded` using the returned status. A failed status lookup is not proof that payment failed. A return-page visit or app redirect is not payment confirmation. Use verified webhooks to update orders even when the customer closes checkout without returning. If the app cannot open, keep a useful payment-status page available on your website. ### Retrieve a payment or QR image `GET /payments/{id}` returns the payment's current details with HTTP 200. Use the FahiPay payment `id` in the path, rather than your order reference. ```sh curl 'https://api.fahipay.mv/merchants/payments/YOUR_PAYMENT_ID' \ -H 'X-API-Key: YOUR_SECRET_KEY' ``` `GET /public/qr/{id}` returns a dynamically generated PayMV QR PNG with `Content-Type: image/png`. No merchant authentication is required. Use the exact `qr_url` returned by payment creation; it can be used as an image URL on your checkout page. The QR is available only while the hosted payment is pending and unexpired. Missing payments, payments that are no longer pending or whose expiry has passed, and authorization-linked payments return HTTP 404. QR responses must not be cached. ### Cancel a payment `POST /payments/{id}/cancel` Cancel a payment before the customer pays. For example, cancel the payment when an order is withdrawn or a checkout is replaced. Only payments with status `pending` can be cancelled. No request body is required. ```sh curl --request POST 'https://api.fahipay.mv/merchants/payments/YOUR_PAYMENT_ID/cancel' \ -H 'X-API-Key: YOUR_SECRET_KEY' ``` A successful request returns HTTP 200 with the payment in `data` and `status: "cancelled"`. Its existing checkout and PayMV QR URLs no longer accept payment. A `payment.cancelled` webhook is generated when webhooks are enabled. | Result | HTTP status | | --- | --- | | Pending payment cancelled | `200` | | Payment already cancelled | `200`; no additional cancellation event | | Unknown payment, or payment outside your merchant account or current mode | `404` | | Payment is paid, rejected, expired, or refunded | `409` with `payment_not_cancellable` | | Method other than POST | `405`; `Allow: POST` | Cancellation does not refund or reverse a completed payment. To request payment again after cancellation, create a new payment with a new reference; reusing the original reference returns the cancelled payment. ### Payment status and amounts | Status | Meaning | | --- | --- | | `pending` | Awaiting customer payment. | | `paid` | Payment completed. | | `rejected` | Payment rejected. | | `expired` | Payment expired. | | `cancelled` | Payment cancelled. | | `refunded` | Payment refunded. | Get Payment and List Payments save a pending payment as `expired` when its expiry has passed, unless a payment attempt is still processing. Each expiry generates one `payment.expired` webhook when webhooks are enabled; repeated reads do not generate another event. A pending payment with no expiry remains pending. Other statuses are unchanged. Only rows returned by the lookup or list are updated. `amount` is the gross payment amount. `fee` and `net_amount` are null until paid. For a paid payment of 2000 laari with a 40-laari fee, `fee` is `40` and `net_amount` is `1960`. Fees are returned as non-negative amounts. Net amount represents proceeds after the payment fee; it does not represent payout status or include later refunds or adjustments. Example fees in this guide are illustrative. `terminal_label` is returned in hosted payment responses and webhooks, or null when no label was supplied. It is omitted for authorization payments. A matching creation retry keeps the original label. `bill_number` is supported when creating hosted payments, payment requests, and charging authorizations, and is returned in payment responses and payment webhooks. It is null when not supplied, including for older payments. Bill numbers may be reused across payments; each new payment still needs a unique `reference`. A matching creation retry keeps the original bill number. `paid_at` is the original payment completion time and is null before payment or when unavailable. `payment_reference` is the FahiPay transaction reference and is null when unavailable; it is separate from your `reference`. ## Payment requests ### Sandbox payment requests When your merchant account is in **sandbox mode**, send payment requests to **`test@fp`**. Set `customer.type` to `vpa` and `customer.value` to `test@fp`. Alternatively, use `customer.type: email` with `customer.value: test@fahipay.mv`. The base URL stays the same. Example body for `POST /payment-requests` (1000 laari = MVR 10.00): ```json { "reference": "SANDBOX-REQUEST-2026-001", "amount": 1000, "customer": { "type": "vpa", "value": "test@fp" } } ``` Use a new reference for each new request. Sandbox requests accept only the VPA `test@fp` (or `test@fahipay`) or the email `test@fahipay.mv`. Other VPAs and email addresses, and all mobile numbers, return HTTP 400 with `sandbox_recipient_not_allowed`. Live merchant requests are unaffected. Payment requests appear directly in the recipient's FahiPay app. They do not have public links or an `approval_url`. Payment requests let you ask a customer to pay a specific amount using their mobile number, email address, or FahiPay VPA. Existing FahiPay users can receive the request in the FahiPay app. Mobile and email requests can also be delivered through supported external channels. FahiPay handles delivery. The response includes the customer identifier, request status, and payment details. `POST /payment-requests` ```sh curl 'https://api.fahipay.mv/merchants/payment-requests' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'Content-Type: application/json' \ --data '{ "reference": "SCHOOL-ACTIVITY-2026-001", "amount": 75000, "customer": {"type": "vpa", "value": "test@fp"}, "title": "School activity fee", "description": "Activity fee for January 2026", "terminal_label": "COUNTER01", "bill_number": "ACT-2026-001", "return_url": "https://merchant.example/payment-requests/complete" }' ``` | Field | Required | Meaning | | --- | --- | --- | | `reference` | Yes | Your unique reference for this payment request. It must not already be used by a hosted payment, authorization charge, or payment request for your merchant. | | `amount` | Yes | Integer from 1 to 9,999,999,999 laari. | | `customer.type` | Yes | `mobile`, `email`, or `vpa`. | | `customer.value` | Yes | Customer mobile number, email address, or FahiPay VPA. VPA may be sent as `name`, `name@fp`, or `name@fahipay`; FahiPay stores and matches the name part. | | `title` | No | Optional title, matching hosted payment behavior. | | `description` | No | Optional description, matching hosted payment behavior. | | `terminal_label` | No | Optional outlet/counter label, maximum 10 characters. | | `bill_number` | No | Optional bill number using the same rules as hosted payments. | | `return_url` | No | Optional merchant URL. Do not use a return URL visit as payment confirmation. | | `expires_at` | No | Future expiry timestamp. Omit or use null for the default 24-hour expiry. | Successful creation returns HTTP 201: ```json { "data": { "id": "r1234567890b4cde8f0123456789abcd", "reference": "SCHOOL-ACTIVITY-2026-001", "amount": 75000, "fee": null, "net_amount": null, "title": "School activity fee", "description": "Activity fee for January 2026", "terminal_label": "COUNTER01", "bill_number": "ACT-2026-001", "status": "pending", "payment_id": null, "payment_reference": null, "customer": {"type": "vpa", "value": "test@fp"}, "return_url": "https://merchant.example/payment-requests/complete", "created_at": "2026-01-01T12:00:00+05:00", "paid_at": null, "expires_at": "2026-01-02T12:00:00+05:00", "cancelled_at": null, "rejected_at": null } } ``` When the customer pays, the request becomes `paid`, `fee` and `net_amount` are populated, and `payment_id` / `payment_reference` identify the normal paid payment created from the request. Confirm payment through a verified `payment_request.paid` or `payment.paid` webhook, or by retrieving the request. `GET /payment-requests` lists requests with cursor pagination. It supports `status`, `limit`, `after`, `terminal_label`, `bill_number`, `created_from`, and `created_to`. `GET /payment-requests/{id}` retrieves one request. `POST /payment-requests/{id}/cancel` cancels a pending request before it is paid. Paid, rejected, expired, refunded, or processing requests cannot be cancelled. Cancellation returns the request with `status: "cancelled"` and emits `payment_request.cancelled`. Payment-request limits after trimming: `reference` 100 characters, `title` 255, `description` 500, and `return_url` 2048. A retry must keep the same reference, amount, customer, title, description, terminal_label, bill_number, and return_url. If expires_at is supplied on the retry, it must match the original deadline. Changed details return HTTP 409 with reference_conflict. A payment-request reference also reserves that reference for its eventual payment; do not reuse it to create a separate hosted payment or authorization charge. ## Customer authorizations An authorization records customer approval for future payments within agreed limits. It must become `active` before you submit a new charge. Your application decides when to charge; selecting a recurring type does not schedule payments automatically. | Type | Use cases | | --- | --- | | `recurring` | Subscription renewals, memberships, and scheduled instalments. | | `unscheduled` | Usage-based billing and repeat purchases at irregular intervals. This is the default type. | | `one_time` | A single successful payment, such as charging an order when it is ready for fulfilment. | ### Request customer approval `POST /authorizations` For example, a gym offers a membership at **MVR 500.00 per month**. When member 1042 signs up, the gym requests approval for up to 12 monthly payments. The customer reviews and approves the arrangement once; the gym then submits each monthly charge when it is due. The request below limits each charge to **50,000 laari (MVR 500.00)**, spending in each calendar month to the same amount, and total spending to **600,000 laari (MVR 6,000.00)** across at most 12 successful payments. The reference identifies this member's membership arrangement. ```sh curl 'https://api.fahipay.mv/merchants/authorizations' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'Content-Type: application/json' \ --data '{ "reference": "GYM-MEMBER-1042-2026", "title": "Monthly gym membership", "description": "Up to 12 monthly gym membership payments of MVR 500.00.", "type": "recurring", "limits": { "per_payment": 50000, "period": {"interval": "month", "amount": 50000}, "total": 600000, "max_payments": 12 }, "return_url": "https://merchant.example/authorizations/complete" }' ``` | Field | Required | Meaning | | --- | --- | --- | | `reference` | Yes | Your nonblank authorization reference, up to 128 characters after trimming. | | `title` | Yes | Clear description of the approval being requested, up to 255 characters after trimming. | | `description` | No | Additional details, up to 500 characters after trimming. | | `type` | No | `recurring`, `unscheduled`, or `one_time`. Omitted or null defaults to `unscheduled`. | | `limits` | No | Spending and successful-payment-count limits described below. | | `expires_at` | No | Future deadline for making new charges. Omitted or null means no overall authorization expiry. | | `return_url` | No | Your website URL for the customer to return to after approving or rejecting the authorization. A matching retry keeps the original return URL. | The response is HTTP 201 with the authorization in `data`: ```json { "data": { "id": "b1234567890b4cde8f0123456789abcd", "reference": "GYM-MEMBER-1042-2026", "title": "Monthly gym membership", "description": "Up to 12 monthly gym membership payments of MVR 500.00.", "type": "recurring", "currency": "MVR", "limits": { "per_payment": 50000, "period": {"interval": "month", "amount": 50000}, "total": 600000, "max_payments": 12 }, "usage": {"payment_count": 0, "total_paid": 0}, "status": "pending", "approval_url": "https://fahipay.mv/p/YOUR_PUBLIC_LINK_ID", "return_url": "https://merchant.example/authorizations/complete", "approval_expires_at": "2026-01-02T12:00:00+05:00", "expires_at": null, "approved_at": null, "rejected_at": null, "revoked_at": null, "last_used_at": null, "created_at": "2026-01-01T12:00:00+05:00", "updated_at": "2026-01-01T12:00:00+05:00" } } ``` Save the authorization `id` and direct the customer to `approval_url` before `approval_expires_at`. If you supplied `return_url`, FahiPay can send the customer back to your website after they approve or reject the request. New authorizations give the customer **24 hours from creation** to approve. That deadline applies to obtaining approval; `expires_at` applies to future charges. Existing authorizations retain their original approval deadlines, so always use the returned values. Confirm approval through a verified `authorization.approved` webhook or `GET /authorizations/{id}` before charging. In the gym example, creating the authorization does not collect the membership fee. After approval, the gym submits January's charge as shown below. February's charge uses the same authorization ID and a new payment reference, such as `GYM-MEMBER-1042-2026-02`. The gym's billing system schedules those requests; FahiPay checks the approved limits for each charge. Other arrangements can use the same approval flow: | Example | Type and suitable limits | | --- | --- | | An appliance sold for MVR 3,000.00 in six monthly instalments of MVR 500.00. | `recurring`; per-payment and monthly limits of `50000` laari, a total limit of `300000` laari, and `max_payments: 6`. | | A laundry service bills a customer after each collection, up to MVR 100.00 per visit and MVR 400.00 per month. | `unscheduled`; a per-payment limit of `10000` laari and a monthly limit of `40000` laari. Charge the actual agreed amount for each visit. | ### Spending limits | Limit | Meaning | | --- | --- | | `limits.per_payment` | Maximum amount for one charge, in laari. | | `limits.period.amount` | Maximum successful spending during the selected calendar period, in laari. Requires `limits.period.interval`. | | `limits.period.interval` | `day`, `week`, `month`, or `year`. | | `limits.total` | Maximum lifetime successful spending, in laari. | | `limits.max_payments` | Maximum number of successful payments. | All configured limits apply together. Omit a limit or set it to null for no limit of that kind. Monetary limits must be integers from `1` to `999999999999999999` laari. Payment-count limits must be integers from `1` to `4294967295`. A `one_time` authorization allows only one successful payment; omit `max_payments` or set it to `1`. Period boundaries use +05:00: days start at midnight, weeks start on Sunday, months start on the first, and years start on January 1. They are calendar periods, rather than rolling durations. For example, a monthly limit resets at the start of the next calendar month. Lifetime total spending and payment count do not reset with the period. Use integer-safe handling for large monetary values. JavaScript's ordinary `Number` cannot exactly represent every integer allowed by these limits; preserve exact integers when parsing and serializing amounts beyond its safe range. ### Charge an active authorization `POST /authorizations/{id}/charge` Continuing the gym example, collect the member's January fee of **50,000 laari (MVR 500.00)** after the authorization becomes active. Include the member and billing month in your payment reference so it is unique across the gym's payments. ```sh curl 'https://api.fahipay.mv/merchants/authorizations/YOUR_AUTHORIZATION_ID/charge' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ -H 'Idempotency-Key: GYM-MEMBER-1042-2026-01' \ -H 'Content-Type: application/json' \ --data '{ "reference": "GYM-MEMBER-1042-2026-01", "amount": 50000, "title": "Monthly gym membership", "description": "Gym membership for member 1042, January 2026", "bill_number": "GYM/2026/01-1042" }' ``` `reference` and `amount` are required. The reference must contain 1–100 characters after trimming. The amount must be an integer from 1 to 9,999,999,999 laari. `title`, `description`, and `bill_number` are optional. Bill numbers follow the same rules as hosted payments: 1 to 25 characters after trimming, using `A-Z`, `a-z`, `0-9`, spaces, `-`, `_`, `.`, and `/`. Omit, use null, or send a blank string for no bill number. Unsupported characters or excess length return HTTP 400. Matching retries keep the original bill number. Each charge's reference must be unique across all payments for your merchant, including hosted payments and charges under other authorizations. Reuse it only when retrying that same charge. Do not send currency or checkout fields such as `return_url` or `expires_at`. A successful charge returns HTTP 201 with the paid payment in `data`: ```json { "data": { "id": "c1234567890b4cde8f0123456789abcd", "reference": "GYM-MEMBER-1042-2026-01", "amount": 50000, "fee": 1000, "net_amount": 49000, "title": "Monthly gym membership", "description": "Gym membership for member 1042, January 2026", "bill_number": "GYM/2026/01-1042", "status": "paid", "payment_reference": "FP202601010001", "created_at": "2026-01-01T12:05:00+05:00", "paid_at": "2026-01-01T12:05:00+05:00" } } ``` Authorization payments include `payment_reference` but omit `terminal_label`, `payment_url`, `qr_url`, `return_url`, and `expires_at` in all payment responses, history, and webhooks. They do not require a new hosted checkout. Before taking a new charge, FahiPay checks the customer's available balance. If it is insufficient, the request returns HTTP 402 with `error.code: "insufficient_balance"` and no payment is created by that request. Retry after the customer adds funds, retaining the same authorization, reference and amount. Matching retries of an already successful payment return the original payment without another balance check. New authorization charges also check the customer's wallet limits using their current verification tier and account type. A rejected wallet-limit check returns HTTP 422 with `error.code: "wallet_limit_exceeded"`; no payment is created by that request. Resolve the wallet limit before retrying with the same authorization, reference and amount. Matching retries of successful payments do not repeat the wallet-limit check. ### Retrieve, list payments, or stop future charges | Request | Result | | --- | --- | | `GET /authorizations/{id}` | Current status, limits, lifetime usage, approval URL, and timestamps. | | `GET /authorizations/{id}/payments` | Payments for this authorization, including history after cancellation, revocation, or expiry. | | `POST /authorizations/{id}/cancel` | Cancels a pending authorization or revokes an active one. No request body is needed. | Cancellation returns HTTP 200 with the authorization in `data`. Repeating cancellation of an already cancelled or revoked authorization also returns HTTP 200. A charge in progress prevents cancellation and returns HTTP 409; retry after the charge finishes. Stopping an authorization prevents future charges and does not reverse payments already made. Authorization statuses are `pending`, `active`, `rejected`, `revoked`, `expired`, `cancelled`, `suspended`, and `consumed`. Submit new charges only while the authorization is active, unexpired, and has sufficient remaining limits. Reaching a limit can block a charge even if the returned status is still active. ## Lists and pagination These query parameters apply to `GET /payments`, `GET /authorizations`, and `GET /authorizations/{id}/payments`: | Parameter | Meaning | | --- | --- | | `limit` | Page size, default `20`. Integer values are clamped to `1`–`100`. | | `after` | The `pagination.next_cursor` returned by the previous page. Omit for the first page. | | `status` | Filter by a status applicable to the endpoint, as listed below. Omit to include all applicable statuses. | | Endpoint | Status filter values | | --- | --- | | `GET /payments` | `pending`, `paid`, `rejected`, `expired`, `cancelled`, `refunded` | | `GET /authorizations` | The authorization statuses listed above. | | `GET /authorizations/{id}/payments` | `paid`, `refunded` | For `GET /payments`, `status=pending` includes reserved payments still processing, even after their expiry. It excludes other overdue pending payments without updating those excluded rows. `status=expired` includes overdue, unreserved pending payments together with payments already marked expired. Status filtering and returned statuses use the same time when evaluating the list. For authorization payment history, `status` refers to the charge. Cancelling, revoking, or expiring the authorization does not change the status of its existing payments. Both payment-list endpoints support these creation-time filters: | Parameter | Meaning | | --- | --- | | `created_from` | Include payments created at or after this time (inclusive). | | `created_to` | Include payments created before this time (exclusive). | Time bounds filter `created_at`. Use dates such as `2026-01-01` or timestamps such as `2026-01-01T00:00:00+05:00`. Dates mean midnight; a missing timezone offset means +05:00. Omit either bound for an open-ended range. When both are supplied, `created_from` must be earlier than `created_to`. Blank or invalid dates, reversed or equal ranges, and labels longer than 10 characters return HTTP 400. `GET /payments` additionally supports `terminal_label`: an exact, case-sensitive match of up to 10 characters after trimming. Omit or leave blank for all labels. Unlabelled payments do not match a nonblank label. This filter is not offered for authorization payment history. Both `GET /payments` and `GET /authorizations/{id}/payments` support `bill_number`: an exact, case-sensitive match of up to 25 characters after trimming. Leading zeroes are significant. Omit or leave blank for all bill numbers. Payments without a bill number do not match a nonblank value; no matches returns an empty `data` array. The same character rules as creation apply: `A-Z`, `a-z`, `0-9`, spaces, `-`, `_`, `.`, and `/`. Longer values or unsupported characters return HTTP 400. Combine this filter with status, time bounds, and pagination as needed. On `GET /payments`, it can also be combined with `terminal_label`. For example, find payments for a bill or invoice: ```sh curl --get 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ --data-urlencode 'bill_number=BILL-2026-001' ``` For example, retrieve paid payments from COUNTER01 during January. `--data-urlencode` encodes spaces and preserves `+` offsets if you use timestamps: ```sh curl --get 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ --data-urlencode 'status=paid' \ --data-urlencode 'terminal_label=COUNTER01' \ --data-urlencode 'created_from=2026-01-01' \ --data-urlencode 'created_to=2026-02-01' ``` To filter a time window within a day, include the date and time in both bounds. This example includes payments created from 08:00 up to, but excluding, 16:00 on 10 January 2026 in Maldives time. Substitute today's date for the same window today: ```sh curl --get 'https://api.fahipay.mv/merchants/payments' \ -H 'X-API-Key: YOUR_SECRET_KEY' \ --data-urlencode 'created_from=2026-01-10T08:00:00+05:00' \ --data-urlencode 'created_to=2026-01-10T16:00:00+05:00' ``` ```sh curl 'https://api.fahipay.mv/merchants/payments?limit=20&status=paid' \ -H 'X-API-Key: YOUR_SECRET_KEY' ``` Results are ordered newest first. A list with no matching records returns HTTP 200: ```json { "data": [], "pagination": { "limit": 20, "has_more": false, "next_cursor": null } } ``` While `has_more` is true, pass `next_cursor` as `after` and keep the other filters unchanged, even if the page is short or empty because a payment changed status during the request. Supply each parameter as a single value; arrays and malformed integer limits return HTTP 400. Unknown cursors start at the first page. Use returned cursors and the status values documented for each endpoint to avoid unintended results. ## Retries and references Every new payment must have a reference unique across all payments for your merchant. This requirement applies to both hosted payments and authorization charges, including charges under different authorizations. Save the request reference before sending a creation or charge request. If the connection times out or a server error leaves the outcome uncertain, retry the same payment with the original reference and amount instead of creating a new payment reference. | Operation | Matching retry behavior | | --- | --- | | Create hosted payment | The same reference and amount return the original payment with HTTP 201. The original expiry and terminal label are retained, even after expiry has passed. | | Charge authorization | The same authorization, reference, and amount return the original payment with HTTP 201, including after the authorization stops accepting new charges. | | Create authorization | The same reference in your current mode returns the original authorization with HTTP 201. Changed details do not update it, and retries do not extend the original approval deadline. | | Cancel payment | An already cancelled payment returns HTTP 200 without an additional cancellation event. | | Cancel authorization | An already cancelled or revoked authorization returns HTTP 200. | Changing the amount for an existing payment reference returns HTTP 409. A reference already used by an authorization payment cannot be reused for a hosted payment. The optional `Idempotency-Key` header adds response replay for payment creation and authorization charges. For an expired unpaid hosted payment, create a replacement using a new reference; a matching retry does not reopen it. For HTTP 409 caused by an in-progress operation, wait briefly and retry the original request. For limit or state conflicts, retrieve the authorization and resolve the cause before retrying. Use bounded retries with increasing delays for temporary errors. ### Optional Idempotency-Key After a confirmed failure such as `insufficient_balance` (HTTP 402) or `wallet_limit_exceeded` (HTTP 422), resolve the cause before trying again. If you supplied `Idempotency-Key`, reusing that key replays the saved failure for 24 hours. For a new attempt after a confirmed failure, use a new key while retaining the original payment reference, amount, and authorization ID. Never change keys for an unknown outcome or `idempotency_in_progress`; check the payment status or contact support first. The `Idempotency-Key` header is optional on `POST /payments` and `POST /authorizations/{id}/charge` only. Use a new key for each new operation and reuse it for retries. Accepts 1–255 printable ASCII characters without spaces; keys are case-sensitive and are not trimmed. Keys are scoped by merchant, live/sandbox mode, and operation. Request matching includes the authorization ID (for charges), effective API version, and parsed JSON body. JSON object key order is ignored; values, types, and field presence must remain unchanged. Completed responses are replayed with their original HTTP status and JSON body for 24 hours after completion, including saved validation and payment errors. A replay is a snapshot; retrieve the payment to check its current status. Different request data returns 409 idempotency_conflict. An unfinished request returns 409 idempotency_in_progress and may require support to reconcile an uncertain outcome. Do not switch keys to bypass an unfinished request. Completed keys can be reused after expiry; processing keys do not expire automatically. Existing reference uniqueness rules always apply. Without this header, existing reference-based retry behavior remains unchanged. ```http Idempotency-Key: ORDER-2026-001 ``` Persist the key and request before sending. Resend both unchanged after a timeout. A new key is appropriate for a new attempt after a definitive rejection has been resolved, but must never be used to bypass an uncertain result. Malformed JSON, authentication failures, scope failures, and invalid keys do not reserve an idempotency key. After an unexpected server failure, the key may remain in progress; keep the same key and check the payment status or contact support before trying a new operation. ## Webhooks Configure your merchant webhook URL and enable web notifications in your merchant settings. The endpoint must be a public HTTPS website with a valid certificate. Its domain must resolve exclusively to public IPv4 or IPv6 addresses. IP-address URLs, localhost, embedded credentials, and URL fragments are not supported. Redirects are not followed. FahiPay sends HTTP POST requests containing a JSON event. Requests include `User-Agent: FahiPay-Merchant-Webhook/1.0` and a `FahiPay-Signature` header. A payment event's `data` is the payment object itself; an authorization event's `data` is the authorization object. A payment request event's `data` is the payment request object. | Event | Meaning | | --- | --- | | `payment.created` | A hosted payment was created. | | `payment.paid` | A hosted payment or authorization charge completed. | | `payment.cancelled` | A payment was cancelled. | | `payment.expired` | A hosted payment was marked expired. | | `payment_request.created` | A payment request was created. | | `payment_request.paid` | A customer paid a payment request. | | `payment_request.rejected` | A customer rejected a payment request. | | `payment_request.cancelled` | A pending payment request was cancelled. | | `payment_request.expired` | A pending payment request expired. | | `authorization.created` | An authorization was created and is awaiting approval. | | `authorization.approved` | The customer approved the authorization. | | `authorization.rejected` | The customer rejected the authorization. | | `authorization.cancelled` | A pending authorization was cancelled. | | `authorization.revoked` | An active authorization was revoked. | Example event for the authorization charge above: ```json { "id": "evt_0123456789abcdef0123456789abcdef", "type": "payment.paid", "payload_version": 1, "mode": "active", "created_at": "2026-01-01T07:05:00Z", "data": { "id": "c1234567890b4cde8f0123456789abcd", "reference": "GYM-MEMBER-1042-2026-01", "amount": 50000, "fee": 1000, "net_amount": 49000, "title": "Monthly gym membership", "description": "Gym membership for member 1042, January 2026", "bill_number": "GYM/2026/01-1042", "status": "paid", "payment_reference": "FP202601010001", "created_at": "2026-01-01T12:05:00+05:00", "paid_at": "2026-01-01T12:05:00+05:00" } } ``` The outer `id` identifies the event; `data.id` identifies the resource. `mode` is `active` for live events or `sandbox` for sandbox events. The outer `created_at` is the event creation time; use the payment's `paid_at` for its completion time. `payload_version` identifies the webhook payload format. The current version is `1` and stays the same across events and delivery retries. A new version would indicate a breaking change to the payload format. New optional fields can be added within the same version, so integrations should ignore fields they do not recognize. ### Verify the signature in PHP The signature header has this format: ```text FahiPay-Signature: t=,v1= ``` `v1` identifies the signature scheme: HMAC-SHA256 over the timestamp, a dot, and the raw request body. It stays `v1` across events and delivery retries. A new signature version would indicate a change to the signing or verification rules. This label is separate from the event's `payload_version`. The `FahiPay-Signature` header is required for a valid webhook. A missing or invalid header returns HTTP 401. Configure `FAHIPAY_SECRET_KEY` in your server environment before using the example. The signature is the hexadecimal HMAC-SHA256 of `timestamp + "." + raw_request_body`, using your merchant API `secret_key`. Verify the raw bytes before decoding the JSON. Re-encoding JSON can change those bytes and invalidate the signature. ```php function verifyFahiPaySignature(string $body, string $header, string $secret): bool { if ($secret === '' || !preg_match('/\At=([0-9]{1,12}),v1=([a-f0-9]{64})\z/D', $header, $parts)) { return false; } if (abs(time() - (int) $parts[1]) > 300) { return false; } $expected = hash_hmac('sha256', $parts[1] . '.' . $body, $secret); return hash_equals($expected, $parts[2]); } $body = file_get_contents('php://input'); $header = $_SERVER['HTTP_FAHIPAY_SIGNATURE'] ?? ''; $secret = getenv('FAHIPAY_SECRET_KEY'); if ($body === false || $secret === false || !verifyFahiPaySignature($body, $header, $secret)) { http_response_code(401); exit('Invalid webhook signature.'); } $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR); // Durably save the event, deduplicating by $event['id'], before acknowledging it. // Return HTTP 2xx after acceptance, or if this event was already accepted. ``` This example accepts timestamps within five minutes of your server clock. Keep that clock accurate and load the secret from your own server configuration. The delivery signature timestamp is refreshed for retries; it is separate from the event's `created_at`. ### Acknowledge and process events Verify the signature, save the event durably, and return HTTP 2xx promptly. Process longer tasks after acceptance. Deduplicate by event `id`, and make order fulfilment idempotent by payment `data.id` as well. Repeated delivery must not fulfil an order or credit a customer twice. Failed delivery attempts retry after approximately one minute, then five minutes, up to three failed attempts. Duplicates can occur, and events may arrive out of order. Return HTTP 2xx for an already accepted event. Do not move an order back to pending because an older creation event arrives after payment; retrieve the resource when you need its latest state. Ignore unrecognized event types after durable acceptance so additional events do not break your endpoint. Use payment and authorization retrieval to reconcile missing notifications. The API's resource state is the basis for your payment and billing decisions. ## Errors and retries Use the HTTP status to identify the category, `error.code` to choose the next step, and `error.message` to explain the issue. Validation errors may include `error.field`. Do not identify errors by parsing message text. ```json { "error": { "code": "invalid_request", "message": "amount must be a positive integer in laari (MVR).", "field": "amount" } } ``` **A timeout is not a payment decline.** A request can complete even if your application never receives the response. Keep the original reference and amount when retrying; for charges, also keep the authorization ID. ### What should your integration do? - **Correct the request:** validation, malformed JSON, credentials or permissions need a change before retrying. - **Wait for a change:** insufficient balance, wallet limits, authorization approval and exhausted period limits need customer action or a reset. - **Retry carefully:** processing conflicts and rate limits need a delay. On a timeout or server error, first look up the payment or retry the original request. ### Error-code reference #### Request and access | Code / HTTP | Cause | What to do | Retry | | --- | --- | --- | --- | | `invalid_request` / 400 | A request field is missing, invalid or unsupported. | Correct error.field when supplied. Send amounts as positive integer laari. | Correct the request | | `invalid_json` / 400 | The request body is not valid JSON. | Send one JSON object with Content-Type: application/json. | Correct the request | | `request_too_large` / 400 | The request body exceeds the accepted size. | Reduce the request body and remove unnecessary content. | Correct the request | | `unsupported_api_version` / 400 | FahiPay-Version is empty or unsupported. | Use 2027-07-01, or omit the header to select the latest version. | Correct the header | | `customer_not_found` / 400 | The payment-request recipient could not be resolved. | Check the mobile number, email address or FahiPay VPA with the customer. | Correct the recipient | | `unauthorized` / 401 | Credentials are invalid, expired, missing, or the merchant is inactive. | Check the credential and merchant access. Obtain a new token if yours expired. | Fix access first | | `insufficient_scope` / 403 | The credential does not grant the required operation. | Use a token granted the scope named in the message. | Fix permissions first | | `not_found` / 404 | The resource, route or public QR is unavailable. | Check the URL, resource ID, merchant account and live or sandbox mode. | Check the resource | | `authorization_not_found` / 404 | The charge authorization was not found for this merchant and mode. | Use the authorization ID returned by the API and check the account mode. | Check the resource | | `payment_request_not_found` / 404 | The payment request was not found for this merchant and mode. | Check the request ID and the credentials used to retrieve or cancel it. | Check the resource | | `method_not_allowed` / 405 | This route does not accept the HTTP method used. | Use the documented method; check Allow when it is returned. | Correct the method | #### Payments and customer wallet | Code / HTTP | Cause | What to do | Retry | | --- | --- | --- | --- | | `insufficient_balance` / 402 | The pre-charge check found insufficient customer balance. | Ask the customer to add funds. Retry the same charge only after the balance changes. | After customer action | | `wallet_limit_exceeded` / 422 | The customer wallet-limit check rejected the charge. | The customer may need to wait for a limit reset or change their wallet tier. Do not repeatedly retry an unchanged limit. | After the limit changes | | `reference_conflict` / 409 | The reference already belongs to a different payment, amount or request. | Check your original request. For a retry, retain its reference and amount. Use a new reference only for a genuinely new payment. | Resolve the conflict | | `payment_not_cancellable` / 409 | The payment is no longer pending. | Retrieve the payment. Cancellation cannot reverse a paid payment. | Do not retry unchanged | | `payment_request_not_cancellable` / 409 | The request is no longer pending, has expired, or is being processed. | Retrieve it first. Retry cancellation after processing only if it remains pending and unexpired. | Check the current state | #### Authorization state and limits | Code / HTTP | Cause | What to do | Retry | | --- | --- | --- | --- | | `authorization_unapproved` / 409 | No customer has approved this authorization. | Wait for confirmed approval before submitting a new charge. | After approval | | `authorization_inactive` / 409 | The authorization is not active. | Retrieve it. A revoked or cancelled authorization needs a new customer-approved authorization. | Check the current state | | `authorization_expired` / 409 | The authorization expiry has passed. | Create a new authorization and obtain customer approval. | Use a new authorization | | `authorization_not_pending` / 409 | The authorization is no longer awaiting a decision. | Retrieve its current state before attempting another approval or rejection. | Check the current state | | `authorization_unavailable` / 409 | The authorization changed or is unavailable for this operation. | Retrieve it to check its state, expiry and any operation in progress. | Check the current state | | `authorization_not_cancellable` / 409 | The authorization cannot be cancelled in its current state or while processing. | Retrieve it. If a charge is in progress, wait for its outcome before retrying cancellation. | Check the current state | | `per_payment_limit_exceeded` / 409 | The amount exceeds the approved per-payment limit. | Correct an incorrect amount or obtain approval for appropriate terms. Do not split charges to bypass the approved cap. | Change approved terms | | `period_limit_exceeded` / 409 | The charge exceeds the remaining approved period allowance. | Wait for the period to reset or obtain a new approval. Periods use +05:00; weeks begin on Sunday. | After reset or approval | | `total_limit_exceeded` / 409 | The charge exceeds the remaining lifetime authorization allowance. | Obtain a new authorization if further payments are needed. | Use a new authorization | | `payment_count_limit_exceeded` / 409 | The approved number of payments has been reached. | Obtain a new authorization for further payments. | Use a new authorization | | `currency_mismatch` / 409 | The stored authorization currency does not match MVR. | Check the authorization and contact support if the mismatch persists. Merchant charges use MVR. | Resolve before retrying | #### Temporary and unexpected errors | Code / HTTP | Cause | What to do | Retry | | --- | --- | --- | --- | | `payment_in_progress` / 409 | An operation already holds the authorization processing lock. | Wait briefly and retry with the original authorization, reference and amount. If it persists, contact support. | Wait, then retry | | `payment_conflict` / 409 | A payment operation conflicts with the current resource state. | Retrieve the payment and resolve its state before trying again. | Check the current state | | `conflict` / 409 | The operation conflicts with the current resource state. | Retrieve the resource. If the cause remains unclear, contact support with the resource ID and request time. | Check the current state | | `rate_limited` / 429 | Requests are being restricted. | Reduce request frequency and retry with increasing delays. Honor Retry-After if present; it is not guaranteed. | Back off, then retry | | `internal_error` / 500 | An unexpected server error occurred; the payment outcome may be unknown. | Look up the payment or retry using the original reference and amount. Never create a new reference merely because a request timed out or returned 500. | Reconcile, then retry | | `not_implemented` / 501 | This operation is not available. | Use a supported operation or contact support. | Do not retry unchanged | ### Retry the same payment safely Save the reference before the first request. For each retry, preserve the original amount and reference; authorization charges must also retain the authorization ID. A matching successful retry returns the existing payment, including after its authorization stops accepting new charges. A new reference means a new payment and can charge the customer again. After a timeout, retrieve the payment if you already have its ID. Otherwise, repeat the original request. For transient errors, use bounded retries with increasing delays and a small random variation. For example, wait about 1, 2, 4 and 8 seconds; stop and reconcile if the outcome is still unclear. These delays are an integration example, not a FahiPay retry schedule. Do not automatically retry every 409: inspect the code. Merchant payment retries still support references. Payment creation and authorization charges also support the optional `Idempotency-Key` header. Creating an expired-payment replacement is a new operation and needs a new reference and, if supplied, a new key. ### Unexpected responses and support A web server or access restriction can return a non-JSON response. Handle failed JSON parsing without assuming the payment failed. Log the HTTP status, error code, timestamp, endpoint, reference and resource ID when available. Do not log credentials or access tokens. Share these details with support if an error persists. Unexpected server errors use `internal_error`. Include that code and the request details when contacting support. These error bodies describe the Merchant REST API. OAuth token errors are a separate response contract; do not assume the token endpoint returns this envelope. The OpenAPI download includes response schemas, examples and the error-code catalog. ## Request a feature Missing an API feature? Email [info@fahipay.mv](mailto:info@fahipay.mv?subject=FahiPay%20Merchant%20API%20feature%20request) with a short description of what you're building, the feature you need, and how it would help your integration. ## Postman collection Import [the Postman collection](https://fahipay.mv/developers/docs/merchants/postman_collection.json). OAuth 2.0 is configured at collection level. Set `client_id`, `client_secret` and the space-separated `scope` in collection variables, then send **OAuth → Get access token**. Its response script saves `access_token` for subsequent merchant requests. Public QR requests do not require authentication. Keep credentials and tokens private.