Your credentials identify your merchant account and its current live or sandbox mode. The base URL stays the same in both modes.
Money and amounts
All payments are in MVR. Send monetary amounts as integer laari: 100 laari = MVR 1.00. Use "amount": 2000 for MVR 20.00, and omit currency. The API explorer shows an MVR preview as you edit amounts and monetary limits.
Hosted payments, payment requests, and authorization charges accept amounts from 1 to 9,999,999,999 laari and references of 1–100 characters after trimming. Authorization creation has separate reference and limit rules, shown in the API explorer.
Payment amounts. Responses use amount, fee, and net_amount. The fee and net amount are null until the payment is paid. After payment, the fee is non-negative, and net amount equals amount minus fee.
Customer links use https://fahipay.mv/p/{uuid}: payment_url for hosted payments and approval_url for authorizations. Payment requests appear directly in the recipient's FahiPay app and do not have public links. Use each returned URL as-is; its UUID may differ from the API resource ID. Opening a link does not approve or pay anything.
Create your first payment
Send a reference unique across all your merchant payments and an amount. The response contains a hosted payment_url and a PayMV qr_url.
Response examples use FahiPay URLs with descriptive placeholders, such as https://fahipay.mv/p/YOUR_PUBLIC_LINK_ID. These placeholders are not working links. Use the exact payment, QR, and approval URLs returned by the API; do not construct them from resource IDs.
Send the customer to the payment link or display the returned QR image. Confirm status: "paid" through a verified webhook or a server-side lookup before fulfilling the order.
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.
Save the payment id returned by FahiPay against your order before opening payment_url.
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.
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.
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.
Use an API key or HTTP Basic for straightforward integration with full Merchant API access. For control over which operations an integration can perform, use a Bearer access token with selected permissions.
Send your secret key in X-API-Key. No merchant ID or prefix is needed.
HTTP Basic
Use your merchant ID as the username and your secret key as the password.
Bearer token
Request an access token with the client_credentials grant at https://api.fahipay.mv/merchants/oauth/token, using your OAuth client ID and client secret. Request only the scopes your integration needs, separated by spaces.
Send the returned access_token in Authorization: Bearer YOUR_ACCESS_TOKEN. Each endpoint requires a specific scope. For example, payments:read permits retrieving payment records; payments:create permits creating a payment. Keep access tokens and client secrets on your server.
In Postman, choose OAuth 2.0, select Client Credentials, set the Access Token URL to https://api.fahipay.mv/merchants/oauth/token, enter your OAuth client credentials and required scopes, and choose to send client credentials in the Basic Auth header. If you already have an access token, choose Bearer Token and paste the token without its prefix.
Use one method per request. Authentication precedence is Bearer, then X-API-Key, then HTTP Basic. If the selected method fails, the request returns HTTP 401 without falling back to another method.
Endpoint scopes
API key and HTTP Basic credentials include all Merchant API scopes. Bearer tokens include only their granted scopes. A valid token without the required scope returns HTTP 403 with insufficient_scope; the error message identifies the permission needed. A token with no recognized scopes cannot access authenticated endpoints.
Scope
Permitted operations
payments:create
Create a hosted payment.
payments:read
List payments, retrieve a payment, and list payments under an authorization.
payments:cancel
Cancel a pending payment.
payment_requests:create
Create a payment request.
payment_requests:read
List payment requests and retrieve a payment request.
payment_requests:cancel
Cancel a pending payment request.
authorizations:create
Request customer approval for an authorization.
authorizations:read
List authorizations and retrieve an authorization.
authorizations:charge
Charge an approved authorization.
authorizations:cancel
Cancel pending approval or revoke an active authorization.
Each permission is independent: creating an authorization does not grant permission to charge it. Scopes apply within the authenticated merchant account and its current mode.
Live and sandbox mode
Your credentials use your merchant account's current mode. Switching mode changes which payments and authorizations are available. An active merchant account is required.
For sandbox payment requests, use the FahiPay VPA test@fp. Set customer.type to vpa and customer.value to test@fp when calling POST /payment-requests with your merchant account in sandbox mode. The alias test@fahipay is also accepted. Alternatively, set customer.type to email and customer.value to [email protected]. Other VPAs and email addresses, and all mobile numbers, return HTTP 400 with sandbox_recipient_not_allowed. This restriction does not apply to live merchant requests.
Authorize in the API explorer
Choose Authorize and enter your merchant secret key. Open an endpoint, select Try it out, then Execute. The explorer uses X-API-Key and holds it in memory for this page. To use HTTP Basic or Bearer authentication, follow the cURL examples above or import the OpenAPI specification into Postman.
The public QR endpoint does not require credentials.
Receive payment status changes and customer approval decisions at your merchant webhook URL.
Set up your endpoint
Configure your webhook URL and enable web notifications in your merchant settings. Use a public HTTPS website with a valid certificate. Its domain must resolve exclusively to public addresses. IP-address URLs, localhost, embedded credentials, and URL fragments are not supported. Redirects are not followed.
FahiPay sends JSON in an HTTP POST with FahiPay-Signature and User-Agent: FahiPay-Merchant-Webhook/1.0.
Hosted payment creation accepts an optional metadata object for your own identifiers. It supports up to 50 string values, with keys of 1–40 characters and values up to 500 characters. Metadata is preserved without trimming and included in authenticated payment responses and payment webhooks. It is not displayed on public checkout. A creation retry returns the original metadata. Omit it or send {} for no metadata; do not include secrets or sensitive payment information.
Event types
Event
What happened
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.
Event payload
The outer id identifies the event; data.id identifies the payment, payment request, or authorization. mode is active for live events or sandbox for sandbox events. Use a payment's paid_at for its completion time.
The header has the format t=<unix_timestamp>,v1=<hex_signature>. Verify HMAC-SHA256 over timestamp + "." + raw_request_body using your merchant secret_key, before decoding the JSON.
Configure FAHIPAY_SECRET_KEY in your server environment. The example accepts signature timestamps within five minutes of your server clock.
PHP · Signature verification
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.
Accept events and handle retries
Verify the signature, save the event durably, then return HTTP 2xx promptly. Deduplicate by event id and make fulfilment idempotent by payment data.id. Return HTTP 2xx for already accepted events.
Failed deliveries retry after approximately one minute, then five minutes, up to three failed attempts. Events may arrive more than once or out of order. Retrieve the resource when you need its latest state, and reconcile missing notifications with the API.
payload_version remains the integer 1. The signature label v1 identifies the signing scheme. These are independent of the API version date.
Merchant API / Integration
Errors & retries
Understand why a request failed, what to change, and when it is safe to try again.
A timeout is not a payment decline. The payment may already have completed. Keep the original reference and amount when retrying; a new reference can create another payment.
Read an error response
error.code is the stable value your application should handle. error.message explains the issue. error.field identifies a request field when applicable. Do not parse message text to decide what happened.
Choose the next step
Correct the request. Fix invalid fields, credentials or permissions before resending.
Wait for a change. Balance, wallet limits and approval may need customer action or a period reset.
Retry carefully. Wait on processing conflicts and rate limits. Reconcile an uncertain payment or retry its original reference.
Find an error code
33 error codes
No matching code. Try a shorter term, or use the HTTP status to narrow the issue.
Request and access
Swipe horizontally to read all columns.
Error code
HTTP
Meaning and resolution
invalid_request
400
A request field is missing, invalid or unsupported.
Correct error.field when supplied. Send amounts as positive integer laari.
Retry: Correct the request.
invalid_json
400
The request body is not valid JSON.
Send one JSON object with Content-Type: application/json.
Retry: Correct the request.
request_too_large
400
The request body exceeds the accepted size.
Reduce the request body and remove unnecessary content.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: Fix access first.
insufficient_scope
403
The credential does not grant the required operation.
Use a token granted the scope named in the message.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: Correct the method.
Payments and customer wallet
Swipe horizontally to read all columns.
Error code
HTTP
Meaning and resolution
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.
Retry: 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.
Retry: 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.
Retry: Resolve the conflict.
payment_not_cancellable
409
The payment is no longer pending.
Retrieve the payment. Cancellation cannot reverse a paid payment.
Retry: 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.
Retry: Check the current state.
Authorization state and limits
Swipe horizontally to read all columns.
Error code
HTTP
Meaning and resolution
authorization_unapproved
409
No customer has approved this authorization.
Wait for confirmed approval before submitting a new charge.
Retry: After approval.
authorization_inactive
409
The authorization is not active.
Retrieve it. A revoked or cancelled authorization needs a new customer-approved authorization.
Retry: Check the current state.
authorization_expired
409
The authorization expiry has passed.
Create a new authorization and obtain customer approval.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: Use a new authorization.
payment_count_limit_exceeded
409
The approved number of payments has been reached.
Obtain a new authorization for further payments.
Retry: 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.
Retry: Resolve before retrying.
Temporary and unexpected errors
Swipe horizontally to read all columns.
Error code
HTTP
Meaning and resolution
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.
Retry: 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.
Retry: 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.
Retry: 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.
Retry: Back off, then retry.
invalid_idempotency_key
400
The header is empty, too long, contains spaces or contains non-printable/non-ASCII characters.
Retry: Correct the header.
idempotency_conflict
409
This key was already used with different request data.
Retry: Retry the original request unchanged; use a new key only for a new operation.
idempotency_in_progress
409
The original request is processing or has an uncertain outcome awaiting reconciliation.
Retry: Wait and retry with the same key. Contact support if it persists; do not bypass it with a new key.
idempotency_unavailable
503
The original request record could not be read during a concurrent change.
Retry: Retry with the same key and request.
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.
Retry: Reconcile, then retry.
not_implemented
501
This operation is not available.
Use a supported operation or contact support.
Retry: Do not retry unchanged.
Retry without creating another payment
Save your reference before sending a payment request. Keep the same reference and amount on retries; authorization charges must also keep the same authorization ID. A matching successful retry returns the existing payment, including after the authorization stops accepting new charges.
If you already have a payment ID, retrieve the payment after a timeout. Otherwise, repeat the original request. For temporary failures, use bounded retries with increasing delays and a small random variation—for example, approximately 1, 2, 4 and 8 seconds. Stop and reconcile if the outcome remains unclear. This is an example for your integration, not an automatic FahiPay retry schedule.
Do not automatically retry every 409.payment_in_progress calls for a delay; authorization_expired requires a new approval; reference_conflict requires checking the original request. Reference-based retries remain supported. Payment creation and authorization charges also accept an optional Idempotency-Key header.
Handle an unexpected response
Access restrictions or a web server can return a non-JSON response. Handle failed JSON parsing without assuming the payment failed. For support, record the HTTP status, code, request time, endpoint, reference and resource ID when available. Never include API keys, client secrets or access tokens.
This guide describes the Merchant REST API. The OAuth token endpoint has a separate error contract; do not assume it uses this response envelope.
Optional idempotency key
Send Idempotency-Key on POST /payments or POST /authorizations/{id}/charge. Use 1–255 printable ASCII characters without spaces. Save one key per operation and reuse it with the unchanged request on retries. Keys are scoped by merchant, live/sandbox mode, and operation.
Completed responses, including saved validation and payment errors, are replayed with their original HTTP status and JSON body for 24 hours after completion. JSON object key order is ignored; values, types, field presence, authorization ID, and API version must match. Retrieve the payment to check its current status.
Changed request data returns idempotency_conflict (409). An unfinished request returns idempotency_in_progress (409); it may need support reconciliation after a crash. Do not change keys to bypass an uncertain result. Processing keys do not expire automatically. Your existing payment reference uniqueness rules still apply.
After a confirmed failure such as insufficient_balance (402) or wallet_limit_exceeded (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; reconcile those first.
Pagination
List responses contain data and pagination. Pass pagination.next_cursor as after for the next page. Preserve IDs and references exactly, including leading zeroes. Keep filters unchanged when requesting the next page.
Authorization payment history offers paid and refunded as status filters. Both payment-list endpoints support created_from (inclusive) and created_to (exclusive). Both payment-list endpoints support bill_number (up to 25 characters) as an exact, case-sensitive filter. GET /payments also offers terminal_label (up to 10 characters) as an exact, case-sensitive filter. Both values are trimmed; omit or leave blank to skip a filter. Leading zeroes in bill numbers are significant. Bill numbers allow A-Z, a-z, 0-9, spaces, -, _, ., and /. For 08:00 to 16:00 on a specific day, use created_from=2026-01-10T08:00:00+05:00 and created_to=2026-01-10T16:00:00+05:00, replacing the example date with your chosen date. Dates without a time mean midnight in +05:00.
Merchant API / Integration
API versioning
This documentation covers Merchant API version 2027-07-01.
API version
The date identifies the API release covered by this documentation. Compatible additions may be introduced within the same version. Breaking changes receive a new version.
Send the optional FahiPay-Version: 2027-07-01 request header to select this release. If omitted, the server uses the latest API version. Pin a version when you want to control when your integration adopts a new release.
The FahiPay-Version response header identifies the version used. An unsupported date or empty value returns HTTP 400 with unsupported_api_version; that error response identifies the latest supported version.
Ignore unrecognized response fields so compatible additions do not interrupt your integration.
Webhook payload version
The webhook payload_version is the integer 1. It stays the same across events and delivery retries. A new value would indicate a breaking change to the payload format.
Signature version
v1 in the signature header identifies HMAC-SHA256 signing over the timestamp, a dot, and the raw request body. It stays the same across events and retries. A change to signing or verification rules would use a new signature version.
Merchant API / Developer tools
Merchant API explorer
Enter your API key, edit a request, and inspect the response.
Choose Authorize and enter your merchant secret key from the merchant portal. Credentials are held in memory and are not saved across reloads.
Requests use your merchant account's current live or sandbox mode. Creating or cancelling payments and payment requests, creating or cancelling authorizations, and charging authorizations affect that account.
Open this page on https://fahipay.mv to execute requests. Access from another origin requires the API to allow that origin. You can also import the OpenAPI file into Postman.