1. Withdraw API Overview

The Withdraw API lets your backend (merchant or partner app) request a payout from FrillPay. Use it for server-to-server calls only.

Typical flow
  1. Your backend creates a signed withdraw request using your Merchant API Key and Secret Hex / Secret Token.
  2. You send a POST request to the Withdraw endpoint with JSON body and required headers.
  3. FrillPay validates authentication (HMAC), timestamp, nonce, parameters, limits, KYC, balance, and risk rules.
  4. On success, FrillPay creates a withdraw transaction in pending/processing.
  5. You get an immediate HTTP response, plus (optional) async callback when completed or failed.

Important: never call this endpoint from browser or mobile apps.

2. When to Use the Withdraw API
  • Withdraw funds from your FrillPay balance to destinations supported by FrillPay (wallet, bank, or other payout channels).
  • Automate payouts programmatically instead of using the dashboard.
  • Need idempotent, auditable withdraw requests, each identified by your own requestedId.

Do not use this API for customer deposits or top-ups; use the Deposit / Payment API.

3. Endpoint & HTTP Method
  • HTTP Method: POST
  • Endpoint Path: /v1/withdraw/fp-withdraw.php
  • Base URL: https://api.frillpay.com

Full example: https://api.frillpay.com/v1/withdraw/fp-withdraw.php

Use HTTPS only, send Content-Type: application/json, and include HMAC authentication headers.

4. Authentication & Security (HMAC Headers)

Withdraw API uses the same HMAC-SHA256 signing flow as Deposit.

  • FP-KEY – Merchant API Key (public identifier)
  • FP-TS – Unix timestamp (seconds) when request is generated
  • FP-NONCE – Random hex string to keep each request unique
  • FP-SIG – HMAC-SHA256 signature over method, path, query string, timestamp, nonce, raw JSON body, and your Secret Hex / Token
General rules
  • Clock skew must be within the allowed window (e.g. ±5 minutes).
  • Do not reuse nonces; replay attempts can be blocked.
  • Keep the Secret Hex private; never expose it in frontends.
5. Request Body – Create Withdraw Request
Core fields
  • requestedId (string, required) – your unique ID for this withdraw; used for idempotency.
  • amount (number, required) – withdraw amount, respecting min/max limits and available balance.
  • email (string, required) – Enter the FrillPay account email of the user who owns the wallet. The withdrawal will always be processed against the wallet registered with this email.
Recommended notes
  • Send numeric values as numbers, not strings.
  • Ensure the amount is already in the expected currency.
  • If you support multiple currencies, document the currency field and allowed codes.
6. Idempotency & requestedId
  • Each withdraw request must have a unique requestedId.
  • Sending the same requestedId with the same parameters should return the same transaction/status (no duplicates).
  • Sending the same requestedId with different parameters may be rejected as an idempotency conflict.
  • Use requestedId to align your logs with FrillPay transaction logs for reconciliation.
7. Language Examples (Placeholder)
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);
}
8. Withdraw API Response (No Callback)

The Withdraw API does not send any background callback or webhook. All results (success or error) are returned directly in the same HTTP response of your request.

If the request passes all checks and a withdraw is successfully completed, you will receive an HTTP 200 response with a simple JSON body. If any validation, authentication or internal error occurs (for example codes FP-1013 to FP-1025 or FP-1099), the API returns an HTTP 4xx/5xx status with an error JSON.

Successful Withdraw Response

When the withdraw is created and processed successfully, the API returns:

{
  "code": "200",
  "message": "Payment processed successfully."
}

You should treat this as a final confirmation that:
• the merchant wallet has been debited (amount + charges), and
• the user wallet has been credited with the net amount after charges.

Validation / Business Rule Error Response (4xx)

If the withdraw request fails during validation or business checks, the API returns an HTTP 400 (or 409 for duplicate requests) with a JSON body that always includes a FrillPay error code and a human-readable message. For example:

{
  "code": "FP-1013",
  "message": "Invalid amount – amount must be greater than zero."
}

Other possible error codes include (non-exhaustive):

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
Internal Server Error Response (5xx)

If an unexpected exception occurs on the server, the API returns HTTP 500 with:

{
  "code": "FP-1099",
  "message": "Internal Server Error - The server encountered an unexpected condition."
}

In these cases you should:
• log the full HTTP request and response for debugging, and
• optionally retry the request later with a new requestedId if you are sure that the previous attempt did not complete.

Important: Always rely on the code field to determine whether the operation was successful (200) or failed (FP-10xx / FP-1099). There is no separate webhook or callback – your integration should complete all status handling based on this direct API response.

FAQs

Withdraw API uses the same HMAC-SHA256 authentication used across all FrillPay APIs. Build a canonical string using: METHOD, PATH, sorted query (auth removed), SHA256(body), FP-TS, FP-NONCE, then sign with your merchant secret (hex). Send FP-KEY, FP-TS, FP-NONCE, FP-SIG.

FP-TS must be a UNIX timestamp in seconds. Allowed drift is ±300 seconds. Keep your server clock NTP-synced.

FP-NONCE must be unique for every request. Reusing a nonce triggers 409 FP-1007 (replay detected). Always generate a fresh nonce for retries.

Prefer sending authentication values via headers: FP-KEY, FP-TS, FP-NONCE, FP-SIG. If included in the body or query, exclude them from the canonical query before signing.

Minimum fields: requestedId (string), amount (> 0), email (valid FrillPay wallet owner).

No. Withdraw API returns the final status in the same HTTP response.
✔ No webhook ✔ No asynchronous callback
The response will be either:
  • {"code":"200","message":"Payment processed successfully."}
  • or an FP-10xx/FP-1099 error JSON

Sandbox mode is controlled from the Merchant Portal.

When Sandbox is ON:
  • Withdraws run in test tables
  • Only Test accounts can receive money
  • No real balance movement occurs
When Sandbox is OFF, all withdraws run in live mode.

Withdraw API immediately returns final status:

Success:
{"code":"200","message":"Payment processed successfully."}
Error: FP-10xx validation errors or FP-1099 internal error.

Common reasons:
  • Wrong canonical order
  • Including auth params in query signature
  • Hashing modified JSON instead of raw body
  • Incorrect secret key
  • Uppercase/lowercase mismatch in HMAC output

Merchant wallet must have:
amount + merchantWithdrawCharges
If balance is lower, withdraw is rejected with FP-1018.

The configured user withdraw charge percentage is too high for the requested amount, resulting in zero or negative net amount. Reduce user charge or increase withdraw amount.

Prepare JSON body → compute FP-SIG on raw body → send headers (FP-KEY, FP-TS, FP-NONCE, FP-SIG) with Content-Type: application/json.

Withdraw creation endpoint only accepts POST. Any GET/PUT/DELETE request is rejected with FP-1009.

Refunds must be enabled per merchant. If required, contact support to activate refund APIs.
10. Best Practices & Integration Tips
  1. Always sign requests on the backend: never expose Secret Hex or signing logic in frontend/mobile.
  2. Use strong, unique requestedId values: combine internal ID + timestamp + random suffix; log requests and responses.
  3. Handle all statuses: support pending, processing, completed, failed, cancelled.
  4. Proper error handling: retry only on network/timeouts, not on logical errors like insufficient balance.
  5. Security & hardening: validate TLS, restrict calling servers, log FP-TS/FP-NONCE/requestedId.
  6. Testing: use sandbox first; cover edge cases (small/large amounts, invalid email, KYC not done, etc.).