Introduction (Deposit + Withdraw)
FrillPay money movement has two server-to-server APIs with the same HMAC-SHA256 signing: Deposit (collect funds via hosted checkout) and Withdraw (payout from merchant wallet). Keep API key and secret hex on your backend only, reuse the same signing helper for both, and verify callback/webhook signatures before updating status.
Quick Start: Deposit
- Get
FP-KEYand Secret Hex. - Build JSON intent (requestedId, amount, currency, email).
- Sign body hash + canonical string; send
POSTto/v1/deposit/fp-payment.php. - Redirect/open
checkoutUrl; verify callback signature.
Quick Start: Withdraw
- Ensure wallet balance and limits; use unique
requestedId. - Build JSON (requestedId, amount, currency, destination/receiver).
- Sign with same HMAC scheme;
POSTto/v1/withdraw/fp-withdraw.php. - Read HTTP response; handle webhook for final status.
Shared headers: FP-KEY, FP-TS, FP-NONCE, FP-SIG. Keep clocks within +/- 5 minutes and use fresh nonces per request.
- Server-only: keep
FP-KEY+ Secret Hex safe. - JSON body:
requestedId,amount,currency,email. - Sign raw body → headers
FP-KEY,FP-TS,FP-NONCE,FP-SIG. POST/v1/deposit/fp-payment.php, opencheckoutUrl, verify webhook.
Headers to send
FP-KEY(API key)FP-TS(unix seconds, +/- 300s)FP-NONCE(unique each call)FP-SIG(HMAC-SHA256)
Tiny JSON template
{ "requestedId": "YOUR_ID", "amount": 100, "currency": "USD", "email": "user@example.com" }
- Check balance; create unique
requestedId. - JSON body:
requestedId,amount,currency,receiverEmail/destination,remarks. - Sign with same HMAC and headers (
FP-KEY,FP-TS,FP-NONCE,FP-SIG). POST/v1/withdraw/fp-withdraw.php; rely on webhook for final state.
Headers to send
FP-KEY(same key)FP-TS(unix seconds)FP-NONCE(unique per attempt)FP-SIG(same signing helper)
Tiny JSON template
{ "requestedId": "YOUR_ID", "amount": 50, "currency": "USD", "email": "payout@example.com", }
Scroll below for the original Deposit documentation (auth, endpoints, code samples, errors). Simple steps above are enough for most cases.
- Get your Merchant API Key and Merchant Secret Hex / Secret Token.
-
Send a signed
POSTrequest to create payment intent. - Open the hosted checkout URL to confirm or cancel.
- Validate callback signature on your endpoint.
Example: Create Payment Intent
Select your language and copy the snippet. It signs the raw JSON
body using HMAC-SHA256 with your hex secret and sends headers
FP-KEY, FP-TS, FP-NONCE,
FP-SIG.
import time import json import hmac import hashlib import secrets import sys from urllib.parse import urljoin import requests import os BASE_URL = 'https://api.frillpay.com/' PATH = '/v1/deposit/fp-payment.php' API_KEY = 'YOUR_API_KEY_HERE' SECRET_HEX = 'YOUR_SECRET_HEX_HERE' WANT_JSON = True TIMEOUT_S = 20 BASE_URL = os.getenv('FP_BASE_URL', BASE_URL) PATH = os.getenv('FP_PATH', PATH) API_KEY = os.getenv('FP_API_KEY', API_KEY) SECRET_HEX = os.getenv('FP_SECRET_HEX', SECRET_HEX) reqId = '45613' amount = 10 currency = 'USD' email = 'john@example.com' body_dict = { 'requestedId': reqId, 'amount': amount, 'currency': currency, 'email': email, } body = json.dumps(body_dict, separators=(',', ':'), ensure_ascii=False) qs = 'return=json' if WANT_JSON else '' ts = int(time.time()) nonce = secrets.token_hex(12) body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() canonical = ( 'POST' + '\n' + PATH + '\n' + qs + '\n' + body_hash + '\n' + str(ts) + '\n' + nonce ) try: secret_bytes = bytes.fromhex(SECRET_HEX) except ValueError: print('Gateway error: SECRET_HEX is not valid hex', file=sys.stderr) sys.exit(2) fp_sig = hmac.new(secret_bytes, canonical.encode('utf-8'), hashlib.sha256).hexdigest() url = urljoin(BASE_URL.rstrip('/') + '/', PATH.lstrip('/')) if qs: url = f"{url}?{qs}" headers = { 'Content-Type': 'application/json', 'FP-Key': API_KEY, 'FP-Ts': str(ts), 'FP-Nonce': nonce, 'FP-Sig': fp_sig, } try: resp = requests.post( url, data=body.encode('utf-8'), headers=headers, timeout=TIMEOUT_S, allow_redirects=False, ) except requests.RequestException as e: print(f"Gateway error: {e}", file=sys.stderr) sys.exit(502) code = resp.status_code resp_headers = resp.headers resp_text = resp.text if 200 <= code < 300: try: data = resp.json() except ValueError: print(resp_text) sys.exit(502) hosted_url = (data or {}).get('hosted_url', '') if not hosted_url: print(resp_text) sys.exit(502) print(data) sys.exit(0) if 300 <= code < 400: location = resp_headers.get('Location', '').strip() if not location: print('Empty Location', file=sys.stderr) sys.exit(502) print(location) sys.exit(0) print(resp_text) sys.exit(code if code else 1)
<?php const BASE_URL = 'https://api.frillpay.com/'; const PATH = '/v1/deposit/fp-payment.php'; const API_KEY = 'YOUR_API_KEY_HERE'; const SECRET_HEX = 'YOUR_SECRET_HEX_HERE'; const WANT_JSON = true; const TIMEOUT_S = 20; $reqId = '45613'; $amount = 10; $currency= 'USD'; $email = 'john@example.com'; $bodyArr = [ 'requestedId' => $reqId, 'amount' => $amount, 'currency' => $currency, 'email' => $email, ]; $body = json_encode($bodyArr, JSON_UNESCAPED_SLASHES); $qs = WANT_JSON ? 'return=json' : ''; $ts = time(); $nonce = bin2hex(random_bytes(12)); $bodyHash = hash('sha256', $body); $canonical = "POST\n".strtolower(PATH)."\n".$qs."\n".$bodyHash."\n".$ts."\n".$nonce; $fp_sig = hash_hmac('sha256', $canonical, hex2bin(SECRET_HEX)); $url = rtrim(BASE_URL,'/').PATH.($qs ? '?'.$qs : ''); $headers = [ 'Content-Type: application/json', 'FP-Key: '.API_KEY, 'FP-Ts: '.$ts, 'FP-Nonce: '.$nonce, 'FP-Sig: '.$fp_sig, ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => 1, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => 1, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => TIMEOUT_S, CURLOPT_HEADER => 1, CURLOPT_FOLLOWLOCATION => 0, ]); $resp = curl_exec($ch); $err = curl_error($ch); $info = curl_getinfo($ch); curl_close($ch); if ($err) { http_response_code(502); exit("Gateway error: $err"); } $code = (int)($info['http_code'] ?? 0); list($respHeaders, $respBody) = explode("\r\n\r\n", $resp, 2); if ($code >= 200 && $code < 300) { $data = json_decode($respBody, true); if (!is_array($data) || empty($data['hosted_url'])) { http_response_code(502); exit($respBody); } header('Location: '.$data['hosted_url'], true, 302); exit; } if ($code >= 300 && $code < 400 && preg_match('/^Location:\s*(.+)$/im', $respHeaders, $m)) { $hostedUrl = trim($m[1]); if ($hostedUrl === '') { http_response_code(502); exit('Empty Location'); } header('Location: '.$hostedUrl, true, 302); exit; }
import { createHash, createHmac, randomBytes } from "node:crypto"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { exit } from "node:process"; const BASE_URL = "https://api.frillpay.com"; const PATH = "/v1/deposit/fp-payment.php"; const API_KEY = "YOUR_API_KEY_HERE"; const SECRET_HEX = "YOUR_SECRET_HEX_HERE"; const WANT_JSON = true; const TIMEOUT_MS = 20000; const bodyObj = { requestedId: "45613", amount: 10, currency: "USD", email: "john@example.com", }; function buildSignature({ path, qs, body, ts, nonce, secretHex }) { const bodyHash = createHash("sha256").update(body).digest("hex"); const canonical = `POST\n${path.toLowerCase()}\n${qs}\n${bodyHash}\n${ts}\n${nonce}`; const key = Buffer.from(secretHex, "hex"); const fpSig = createHmac("sha256", key).update(canonical).digest("hex"); return { bodyHash, canonical, fpSig }; } async function createPayment() { const body = JSON.stringify(bodyObj); const qs = WANT_JSON ? "return=json" : ""; const ts = Math.floor(Date.now() / 1000); const nonce = randomBytes(12).toString("hex"); const { fpSig } = buildSignature({ path: PATH, qs, body, ts, nonce, secretHex: SECRET_HEX }); const url = `${BASE_URL.replace(/\/$/, "")}${PATH}${qs ? `?${qs}` : ""}`; const headers = { "Content-Type": "application/json", "FP-Key": API_KEY, "FP-Ts": String(ts), "FP-Nonce": nonce, "FP-Sig": fpSig, }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); try { const res = await fetch(url, { method: "POST", body, headers, redirect: "manual", signal: ctrl.signal }); clearTimeout(timer); const code = res.status; const text = await res.text(); if (code >= 200 && code < 300) { let data; try { data = JSON.parse(text); } catch { return { ok: false, code: 502, error: "Invalid JSON from gateway", raw: text }; } const hosted = (data || {}).hosted_url || ""; if (!hosted) { return { ok: false, code: 502, error: "Missing hosted_url", raw: text }; } return { ok: true, code, hosted_url: hosted }; } if (code >= 300 && code < 400) { const loc = (res.headers.get("location") || "").trim(); if (!loc) return { ok: false, code: 502, error: "Empty Location" }; return { ok: true, code, location: loc }; } return { ok: false, code: code || 1, raw: text }; } catch (e) { const msg = e?.name === "AbortError" ? "Gateway timeout" : (e?.message || String(e)); return { ok: false, code: 502, error: `Gateway error: ${msg}` }; } } const __filename = fileURLToPath(import.meta.url); const isMain = process.argv[1] && path.resolve(process.argv[1]) === __filename; if (isMain) { const out = await createPayment(); if (out.ok) { // console.log(out.hosted_url || out.location); console.log(out); process.exit(0); } else { console.error(out.error || out.raw || "Unknown error"); process.exit(out.code || 1); } }
import 'dart:convert' show utf8, jsonEncode, jsonDecode, JsonEncoder; import 'dart:math' show Random; import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart' show sha256, Hmac, Digest; class FrillPayClient { final String baseUrl; final String path; final String apiKey; final String secretHex; final bool wantJson; final Duration timeout; const FrillPayClient({ required this.baseUrl, required this.path, required this.apiKey, required this.secretHex, this.wantJson = true, this.timeout = const Duration(seconds: 20), }); Future<Map<String, dynamic>> createPaymentJson({ required String requestedId, required num amount, required String currency, required String email, }) async { final bodyObj = <String, dynamic>{ 'requestedId': requestedId, 'amount': amount, 'currency': currency, 'email': email, }; final body = jsonEncode(bodyObj); final qs = wantJson ? 'return=json' : ''; final ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); final nonce = _randomHex(12); final sig = _buildSignature( path: path, qs: qs, body: body, ts: ts, nonce: nonce, secretHex: secretHex, ); final url = '${baseUrl.replaceAll(RegExp(r'\/$'), '')}$path${qs.isNotEmpty ? '?$qs' : ''}'; final headers = <String, String>{ 'Content-Type': 'application/json', 'FP-Key': apiKey, 'FP-Ts': ts, 'FP-Nonce': nonce, 'FP-Sig': sig, }; try { final resp = await http .post(Uri.parse(url), headers: headers, body: body) .timeout(timeout); final code = resp.statusCode; final text = resp.body; if (resp.isRedirect || (code >= 300 && code < 400)) { final loc = resp.headers['location']?.trim(); if (loc == null || loc.isEmpty) { return {"ok": false, "code": 502, "error": "Empty Location"}; } return {"ok": true, "code": code, "location": loc}; } if (code >= 200 && code < 300) { try { final data = jsonDecode(text) as Map<String, dynamic>; final hosted = (data['hosted_url'] ?? '').toString(); if (hosted.isEmpty) { return {"ok": false, "code": 502, "error": "Missing hosted_url", "raw": text}; } return {"ok": true, "code": code, "hosted_url": hosted, ...data}; } catch (_) { return {"ok": false, "code": 502, "error": "Invalid JSON from gateway", "raw": text}; } } return {"ok": false, "code": code == 0 ? 1 : code, "error": "Gateway error", "raw": text}; } catch (e) { return {"ok": false, "code": 502, "error": "Gateway error: $e"}; } } // ----- helpers ----- static String _randomHex(int bytesLen) { final r = Random.secure(); final b = List<int>.generate(bytesLen, (_) => r.nextInt(256)); return _toHex(b); } static String _toHex(List<int> bytes) => bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); static List<int> _hexToBytes(String hex) { final h = hex.toLowerCase(); if (h.length.isOdd) { throw ArgumentError('SECRET_HEX must have even length'); } final out = <int>[]; for (int i = 0; i < h.length; i += 2) { out.add(int.parse(h.substring(i, i + 2), radix: 16)); } return out; } static String _buildSignature({ required String path, required String qs, required String body, required String ts, required String nonce, required String secretHex, }) { final bodyHash = sha256.convert(utf8.encode(body)).toString(); final canonical = [ 'POST', path.toLowerCase(), qs, bodyHash, ts, nonce, ].join('\n'); final key = _hexToBytes(secretHex); final hmac = Hmac(sha256, key); final Digest sig = hmac.convert(utf8.encode(canonical)); return sig.toString(); } } Future<void> main() async { const baseUrl = 'https://api.frillpay.com'; const path = '/v1/deposit/fp-payment.php'; const apiKey = 'z5wsovLk95Wv4PcXlu0YvUBefuQzb5y3'; const secretHex = 'ecad8e4a7355ecbc34949adecb06118c0538326bd14404883fe3ef0f04150a4f'; final client = FrillPayClient( baseUrl: baseUrl, path: path, apiKey: apiKey, secretHex: secretHex, wantJson: true, ); final out = await client.createPaymentJson( requestedId: '45613', amount: 10, currency: 'USD', email: 'john@example.com', ); print(const JsonEncoder.withIndent(' ').convert(out)); }
Each request requires headers for verification:
FP-KEY— Merchant API keyFP-SIG— Secret Token / Merchant Secret HexFP-TS— TimestampFP-NONCE— Unique random string
-
POST
/fp-api/fp-payment— Create payment intent.
If the API response returns code: "FP-1000" and HTTP 200, it means the payment intent was created successfully. In this case, redirect the user to the URL provided in hosted_url to continue the payment.
{ "code": "FP-1000", "tr_id": "FP-XXXXXX", "requestedId": "45613", "hosted_url": "https://pay.frillpay.com/v1/checkout?pi=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
After a payment is processed, FrillPay sends a callback (webhook) to your server on your registered Deposit Callback URL. There are two possible types of responses: one for a successful payment and one for a cancelled / failed transaction.
Successful Payment Response
If the payment is completed successfully, your server will receive a JSON response like this:
{ "code": "200", "message": "Payment processed successfully.", "transaction_id": "FP-XXXXXX", "requestedId": "123456", "amount": 200, "fp_ts": 1720000000, "fp_nonce": "abcxyz", "fp_sig": "sha256signature" }
Cancelled / Failed Transaction Response
If the transaction is cancelled by the user or system timeout occurs, FrillPay sends a JSON response like this:
{ "code": "101", "message": "Transaction cancelled by user.", "transaction_id": "FP-XXXXXX", "requestedId": "123456", "amount": 200, "fp_ts": 1720000000, "fp_nonce": "defuvw", "fp_sig": "sha256signature" }
Note: Always verify the fp_sig (HMAC signature) using your secret key to confirm the request authenticity before updating payment status in your system.
Error Responses (Your Server → FrillPay)
{ "code": "FP-1099", "message": "Internal Server Error – The server encountered an unexpected condition." }
| HTTP | Code | Meaning | Solution |
|---|---|---|---|
| 400 | FP-1001 | Bad Request – Required authentication headers are missing (FP-Key, FP-Ts, FP-Nonce, FP-Sig). | Include all required authentication parameters: FP… Include all required authentication parameters: FP-Key, FP-Ts (Unix seconds), FP-Nonce (unique per request), and FP-Sig (HMAC-SHA256 signature). In most integrations these are sent as HTTP headers; if your library does not support custom headers, send them as fp_key, fp_ts, fp_nonce, fp_sig fields in the POST body and sign them accordingly. Read more |
| 400 | FP-1002 | Bad Request – Timestamp must be a whole number (seconds). | Send FP-Ts as an integer Unix timestamp in seconds… Send FP-Ts as an integer Unix timestamp in seconds (e.g., 1717440000). Do not send milliseconds, decimal values, or formatted date strings like "2025-06-01T12:00:00". Make sure your language converts to integer seconds, not milliseconds. Read more |
| 401 | FP-1003 | Unauthorized – Timestamp is outside the allowed window (±300s). | Ensure the server clock of your integration is in … Ensure the server clock of your integration is in sync (NTP) and FP-Ts is current Unix time in seconds. The value must be within ±300 seconds of FrillPay server time. Fix the system time or NTP configuration and regenerate the request. Read more |
| 400 | FP-1004 | Bad Request – API key is required. | Send your merchant API key in FP-Key (or fp_key). … Send your merchant API key in FP-Key (or fp_key). Copy it exactly from your merchant portal, without extra spaces, quotes, or line breaks. Confirm you are using the correct key for the environment (Sandbox vs Live). Read more |
| 401 | FP-1005 | Unauthorized – API key is invalid. | Verify that the FP-Key you are sending exists, is … Verify that the FP-Key you are sending exists, is active, and belongs to the correct environment (Test/Merchant vs Live). Check for typos, truncated values, or using a key from another project. Regenerate or rotate the key in the merchant portal if needed and update it in your configuration. Read more |
| 401 | FP-1006 | Unauthorized – Signature is invalid. | Recalculate FP-Sig exactly as required: use HMAC-S… Recalculate FP-Sig exactly as required: use HMAC-SHA256 with the merchant secret (hex-decoded) over the canonical string (HTTP method, path, sorted query, body hash, FP-Ts, FP-Nonce). Ensure you sign the exact raw body that you send, with the same encoding, and that you output lowercase hex. Any change in spacing, JSON formatting, or query order will change the signature. Read more |
| 409 | FP-1007 | Conflict – This nonce was already used. | Generate a fresh, unique FP-Nonce for every reques… Generate a fresh, unique FP-Nonce for every request (for example, a random 16–32 character hex string or UUID v4). Never reuse a nonce when retrying a failed call. On each retry, create a new nonce and recompute FP-Sig with the new values. Read more |
| 500 | FP-1008 | Internal error – Merchant secret is misconfigured. | Check the merchant secret configured in FrillPay p… Check the merchant secret configured in FrillPay portal. It must be a valid hex string with even length and non-empty. Confirm that your integration uses the same secret, and that Sandbox vs Live secrets are not mixed. If the problem persists, contact support to validate the merchant configuration. Read more |
| 405 | FP-1009 | Method Not Allowed – HTTP method is not allowed for this endpoint. | Use the correct HTTP method for the endpoint. The … Use the correct HTTP method for the endpoint. The withdraw endpoint only accepts POST for creating a withdraw. Do not send GET/PUT/DELETE or browser form redirects directly to this URL. Read more |
| 400 | FP-1010 | Bad Request – The request was invalid or missing required parameters. | Send a valid JSON body with all required fields fo… Send a valid JSON body with all required fields for this endpoint. For withdraw: requestedId (non-empty string), amount (> 0), and email (valid). Check field names and types carefully and avoid sending extra malformed data. Read more |
| 400 | FP-1011 | Invalid token – the provided secret (hex key) is incorrect. | Confirm that you are using the correct merchant se… Confirm that you are using the correct merchant secret/hex key for the API key and environment. Copy the secret exactly from the portal (no trimming, no quotes) and ensure your code hex-decodes it correctly before using it in HMAC. If you rotated the secret recently, update your server configuration accordingly. Read more |
| 409 | FP-1012 | Duplicate Request - This reqestedId is already used for a transaction. | Use a unique requestedId for every new withdraw. S… Use a unique requestedId for every new withdraw. Store the mapping between requestedId and your internal order so that on retries you can detect and handle existing transactions instead of generating a new requestedId. Only reuse the same requestedId if you intentionally want idempotent behavior and are prepared to handle the previous transaction status. Read more |
| 400 | FP-1013 | Invalid amount – amount must be greater than zero. | Ensure the amount field is a positive numeric valu… Ensure the amount field is a positive numeric value greater than 0 (e.g., 100 or 100.00). Do not send negative numbers, zero, or non-numeric strings. Also confirm that you are not accidentally sending the value in the wrong unit (e.g., cents instead of main currency unit). Read more |
| 400 | FP-1014 | Invalid email – a valid receiver email is required. | Provide a valid email address for the receiver in … Provide a valid email address for the receiver in the email field (e.g., customer@example.com). Ensure there are no spaces, invalid characters, or partial values. Use the same email that is registered for the receiver’s FrillPay wallet. Read more |
| 400 | FP-1015 | Merchant does not have a Frillpay wallet account. | Verify that the merchant account (linked with your… Verify that the merchant account (linked with your API key) has a FrillPay wallet created and active in the system. If this is a new merchant, complete the wallet setup/KYC process in the FrillPay portal or contact support to enable the wallet. Read more |
| 400 | FP-1016 | Same account transfer is not allowed. | Make sure the receiver email is different from the… Make sure the receiver email is different from the merchant’s own wallet email. Withdraw should go to another user wallet, not back to the same account. Read more |
| 400 | FP-1017 | Not enough funds to cover the charges. | Check the configured withdraw charges (merchantWit… Check the configured withdraw charges (merchantWithdrawCharges) and the requested amount. The charge portion must be less than the total amount; otherwise, nothing remains for the receiver. Either lower the charge percentage or increase the amount so that amount - user charges > 0 and amount + merchant charges is affordable. Read more |
| 400 | FP-1018 | Merchant account has insufficient balance. | Top up the merchant wallet so that it has at least… Top up the merchant wallet so that it has at least amount + merchant charges available. Before calling the API, you can also fetch or track the merchant wallet balance to avoid sending withdraw requests that cannot be funded. Read more |
| 400 | FP-1019 | User does not have a Frillpay wallet. | Ensure the receiver has a registered and active Fr… Ensure the receiver has a registered and active FrillPay wallet using the same email you are sending in the request. If not, ask the user to create/activate their wallet first, or correct the email if it is incorrect. Read more |
| 400 | FP-1020 | The receiver account is not eligible for this transaction. | Only active accounts can receive funds. Ensure the… Only active accounts can receive funds. Ensure the account is active and eligible to accept transactions. Inactive, suspended, or test-only accounts cannot receive funds. Read more |
| 400 | FP-1021 | User has no access to receive this amount. | The receiver’s account is restricted for Merchant … The receiver’s account is restricted for Merchant Withdrawals. Ensure the account has the necessary permissions enabled to receive funds. Read more |
| 400 | FP-1022 | User charge configuration exceeds the amount. | The withdrawal amount is too small compared to the… The withdrawal amount is too small compared to the user fees. Increase the amount or reduce the fees. Read more |
| 400 | FP-1023 | Merchant wallet not found. | Ensure the receiver has an active wallet/account s… Ensure the receiver has an active wallet/account set up to receive funds. If the account is not properly configured, complete the setup and try again. Read more |
| 400 | FP-1024 | Merchant account has insufficient balance. | During the final balance check/locking, the mercha… During the final balance check/locking, the merchant wallet did not have enough funds. This can happen if balance changed between initial validation and commit. Ask the merchant to top up the wallet, or reduce the amount, and then retry with a new requestedId. Read more |
| 400 | FP-1025 | Receiver wallet not found. | Receiver wallet not found. Ensure the receiver has… Receiver wallet not found. Ensure the receiver has an active wallet/account and completed KYC before sending funds. Read more |
| 500 | FP-1099 | Internal Server Error - An unexpected error occurred on the server. | An internal error occurred in the API. Retry after… An internal error occurred in the API. Retry after a short delay. If the issue persists, contact support with the relevant request details. Read more |
METHOD, PATH, sorted query (auth params removed), SHA256(body), FP-TS, and FP-NONCE, then sign it with your secret (hex). Send FP-KEY, FP-TS, FP-NONCE, FP-SIG.
FP-TS must be UNIX seconds (integer). Allowed drift is ±300 seconds. Keep your servers NTP-synced.
FP-NONCE is a unique value per request. Do not reuse. Reuse may result in a 409 (replay) error.
FP-KEY, FP-TS, FP-NONCE, FP-SIG. If you include them in the body or query, exclude them from the canonical query string before signing.
requestedId (string), amount (> 0) & currency
Sandbox is controlled from the Merchant Portal using a toggle. When you switch it ON, you can run test transactions. The portal also shows your Test Account credentials (e.g., email, password) to use during checkout.
- Open Merchant Portal → Sandbox → Test Account section.
- Turn the Sandbox Toggle ON.
- Use the displayed Test Account details at checkout to complete test payments.
- Sandbox transactions do not move real funds; they are for testing only.
- Hosted checkout and webhook flows are the same; payments remain in a Test state.
- Before going live, switch the Sandbox Toggle OFF.
| Where to find | What you get |
|---|---|
| Merchant Portal → Sandbox → Test Account | Sandbox status (ON/OFF) + Test Account credentials |
| Sandbox ON | Use the shown Test Account at checkout to simulate approve/decline flows |
| Sandbox OFF | All transactions run in Live (real) mode |
transaction_id. Reply 200 {"status":"ok"} on success.
hosted_url for the hosted page. See “Create Deposit Intent” examples in this document.
100.00). Currencies depend on your merchant configuration; start with USD and contact support for more.
200 {"status":"ok"}. On errors: non-2xx with a short JSON message (e.g., {"status":"error","code":"WH-401","message":"Signature verification failed"}). FrillPay retries on non-2xx.
POST.
FP-SIG exactly on the raw body, and send headers (FP-KEY, FP-TS, FP-NONCE, FP-SIG) with Content-Type: application/json. Compare your signature with server logs when debugging.
Withdraw API Overview
Trigger payouts from your backend using the same HMAC-SHA256 signing flow as Deposit. Keep keys and secrets server-side only.
Typical flow
- Create JSON with
requestedId, amount, currency, destination (wallet/bank), receiver email, remarks/metadata. - Sign canonical string (method, path, query, body hash,
FP-TS,FP-NONCE) with secret hex and send headersFP-KEY,FP-TS,FP-NONCE,FP-SIG. - FrillPay validates auth, balance, limits, KYC, and risk.
- Immediate HTTP response; webhook/callback can deliver final state.
Note: Backend/server-to-server only; never embed secrets in frontend or apps.
- Payout from merchant balance to wallets/banks programmatically.
- Automated, idempotent payouts where you control
requestedId. - Server-side flows with HMAC security and optional IP allowlisting.
Use Deposit API for customer deposits/top-ups instead.
- HTTP Method: POST
- Endpoint Path:
/v1/withdraw/fp-withdraw.php - Base URL:
https://api.frillpay.com
HTTPS only; send Content-Type: application/json with HMAC headers.
Same signing as Deposit:
FP-KEY— Merchant API keyFP-TS— Unix seconds (integer)FP-NONCE— Unique per requestFP-SIG— HMAC-SHA256 over canonical string
Keep secret hex private; rotate if compromised; block reused nonces.
success— payout accepted/processedpending/processing— still runningfailed— rejected/blocked
Webhook/callback provides final state; verify signature before updating records.
import time import os import json import hmac import hashlib import requests import sys BASE_URL = 'https://api.frillpay.com/' PATH = '/v1/withdraw/fp-withdraw.php' API_KEY = 'YOUR_API_KEY_HERE' SECRET_HEX = 'YOUR_SECRET_HEX_HERE' WANT_JSON = True TIMEOUT_S = 20 body_dict = { "requestedId": "REQ-XYZ-001", "amount": 100, "email": "cilent@example.com", } body = json.dumps(body_dict, ensure_ascii=False, separators=(',', ':')) qs = 'return=json' if WANT_JSON else '' ts = int(time.time()) nonce = os.urandom(12).hex() method = 'POST' qs_canonical = qs body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() canonical = ( method + "\n" + PATH + "\n" + qs_canonical + "\n" + str(ts) + "\n" + nonce + "\n" + body_hash ) try: secret_bin = bytes.fromhex(SECRET_HEX) except ValueError: raise Exception('Invalid SECRET_HEX') signature = hmac.new(secret_bin, canonical.encode('utf-8'), hashlib.sha256).hexdigest() url = BASE_URL + PATH + ('?' + qs if qs else '') headers = { 'Content-Type': 'application/json', 'X-FP-APIKEY': API_KEY, 'X-FP-TS': str(ts), 'X-FP-NONCE': nonce, 'X-FP-SIGNATURE': signature, } try: resp = requests.post(url, data=body, headers=headers, timeout=TIMEOUT_S) except requests.RequestException as e: sys.exit(f"HTTP request failed: {e}") print(resp.text)
<?php const BASE_URL = 'https://api.frillpay.com/'; const PATH = '/v1/withdraw/fp-withdraw.php'; const API_KEY = 'YOUR_API_KEY_HERE'; const SECRET_HEX = 'YOUR_SECRET_HEX_HERE'; const WANT_JSON = true; const TIMEOUT_S = 20; $bodyArr = [ "requestedId" => "REQ-XYZ-001", "amount" => 100, "email" => "cilent@example.com", ]; $body = json_encode($bodyArr, JSON_UNESCAPED_SLASHES); $qs = WANT_JSON ? 'return=json' : ''; $ts = time(); $nonce = bin2hex(random_bytes(12)); $method = 'POST'; $qsCanonical = $qs; $bodyHash = hash('sha256', $body); $canonical = $method . "\n" . PATH . "\n" . $qsCanonical . "\n" . $ts . "\n" . $nonce . "\n" . $bodyHash; $secretBin = hex2bin(SECRET_HEX); if ($secretBin === false) { throw new Exception('Invalid SECRET_HEX'); } $signature = hash_hmac('sha256', $canonical, $secretBin); $url = BASE_URL . PATH . ($qs ? ('?' . $qs) : ''); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => TIMEOUT_S, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-FP-APIKEY: ' . API_KEY, 'X-FP-TS: ' . $ts, 'X-FP-NONCE: ' . $nonce, 'X-FP-SIGNATURE: ' . $signature, ], ]); $responseBody = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($responseBody === false) { $err = curl_error($ch); curl_close($ch); die("HTTP request failed: $err\n"); } curl_close($ch); header('Content-Type: application/json'); echo $responseBody;
const crypto = require('crypto'); const axios = require('axios'); const BASE_URL = 'https://api.frillpay.com/'; const PATH = '/v1/withdraw/fp-withdraw.php'; const API_KEY = 'YOUR_API_KEY_HERE'; const SECRET_HEX = 'YOUR_SECRET_HEX_HERE'; const WANT_JSON = true; const TIMEOUT_S = 20; const bodyObj = { requestedId: 'REQ-XYZ-001', amount: 100, email: 'cilent@example.com', }; const body = JSON.stringify(bodyObj); const qs = WANT_JSON ? 'return=json' : ''; const ts = Math.floor(Date.now() / 1000); const nonce = crypto.randomBytes(12).toString('hex'); const method = 'POST'; const qsCanonical = qs; const bodyHash = crypto .createHash('sha256') .update(body, 'utf8') .digest('hex'); const canonical = method + '\n' + PATH + '\n' + qsCanonical + '\n' + ts + '\n' + nonce + '\n' + bodyHash; let secretBin; try { secretBin = Buffer.from(SECRET_HEX, 'hex'); } catch (e) { throw new Error('Invalid SECRET_HEX'); } const signature = crypto .createHmac('sha256', secretBin) .update(canonical, 'utf8') .digest('hex'); const url = BASE_URL + PATH + (qs ? ('?' + qs) : ''); const headers = { 'Content-Type': 'application/json', 'X-FP-APIKEY': API_KEY, 'X-FP-TS': String(ts), 'X-FP-NONCE': nonce, 'X-FP-SIGNATURE': signature, }; (async () => { try { const response = await axios.post(url, body, { headers, timeout: TIMEOUT_S * 1000, validateStatus: () => true, }); console.log(response.data); } catch (err) { console.error('HTTP request failed:', err.message || err); process.exit(1); } })();
import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; const String BASE_URL = 'https://api.frillpay.com/'; const String PATH = '/v1/withdraw/fp-withdraw.php'; const String API_KEY = 'YOUR_API_KEY_HERE'; const String SECRET_HEX = 'YOUR_SECRET_HEX_HERE'; const bool WANT_JSON = true; const Duration TIMEOUT = Duration(seconds: 20); void main() async { final Map<String, dynamic> bodyMap = { "requestedId": "REQ-XYZ-001", "amount": 100, "email": "cilent@example.com", }; final String body = jsonEncode(bodyMap); final String qs = WANT_JSON ? 'return=json' : ''; final int ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; final String nonce = _randomHex(12); final String method = 'POST'; final String qsCanonical = qs; final String bodyHash = sha256.convert(utf8.encode(body)).toString(); final String canonical = [ method, PATH, qsCanonical, ts.toString(), nonce, bodyHash, ].join('\n'); final Uint8List secretBin = _hexToBytes(SECRET_HEX); final String signature = Hmac(sha256, secretBin).convert(utf8.encode(canonical)).toString(); final String url = BASE_URL + PATH + (qs.isNotEmpty ? '?$qs' : ''); final Map<String, String> headers = { 'Content-Type': 'application/json', 'X-FP-APIKEY': API_KEY, 'X-FP-TS': ts.toString(), 'X-FP-NONCE': nonce, 'X-FP-SIGNATURE': signature, }; try { final response = await http .post(Uri.parse(url), headers: headers, body: body) .timeout(TIMEOUT); print('HTTP ${response.statusCode}'); print(response.body); } catch (e) { print('HTTP request failed: $e'); } } String _randomHex(int bytesLength) { final rand = Random.secure(); final bytes = List<int>.generate(bytesLength, (_) => rand.nextInt(256)); final StringBuffer sb = StringBuffer(); for (final b in bytes) { sb.write(b.toRadixString(16).padLeft(2, '0')); } return sb.toString(); } Uint8List _hexToBytes(String hex) { final sanitized = hex.trim(); if (sanitized.length % 2 != 0) { throw FormatException('Invalid SECRET_HEX length'); } final bytes = <int>[]; for (int i = 0; i < sanitized.length; i += 2) { final byteStr = sanitized.substring(i, i + 2); final value = int.parse(byteStr, radix: 16); bytes.add(value); } return Uint8List.fromList(bytes); }
- Validate destination (wallet/bank) and KYC before sending.
- Use exponential backoff on transient errors with the same
requestedId. - Capture request/response and signature inputs for audits.
- Verify webhook signatures before marking payout final.