Introduction

FrillPay API uses HMAC-SHA256 signed requests (server → server), hosted checkout, and secure callback verification. Keep your Merchant API Key And Merchant Secret Hex / Secret Token confidential.

Quick Start
  1. Get your Merchant API Key and Merchant Secret Hex / Secret Token.
  2. Send a signed POST request to create payment intent.
  3. Open the hosted checkout URL to confirm or cancel.
  4. 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)

# ---- Request body ----
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));
}
Auth & HMAC Signing

Each request requires headers for verification:

  • FP-KEY — Merchant API key
  • FP-SIG — Secret Token / Merchant Secret Hex
  • FP-TS — Timestamp
  • FP-NONCE — Unique random string
Endpoints
  • POST /fp-api/fp-payment — Create payment intent.
Hosted Payment URL — HTTP 200

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"
}
Webhooks / Callback

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:

{
  "status": "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:

{
  "status": "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.

Errors & Troubleshooting
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
FAQs

We use HMAC-SHA256. Build a canonical string from 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.

Prefer HTTP headers: 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.

Minimum: 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.

  1. Open Merchant Portal → SandboxTest Account section.
  2. Turn the Sandbox Toggle ON.
  3. Use the displayed Test Account details at checkout to complete test payments.
Notes:
  • 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

Apply the same HMAC signing logic on the incoming request (body hash + timestamp + nonce). Enforce the ±300s window and idempotently process each transaction_id. Reply 200 {"status":"ok"} on success.

A JSON payload containing your idempotency key, amount/currency, and a hosted_url for the hosted page. See “Create Deposit Intent” examples in this document.

Wrong canonical order, including auth params in query signature, hashing a modified body (not raw), lowercase/uppercase mismatch, wrong secret, or incorrect timestamp/nonce handling.

Use two decimal places (e.g., 100.00). Currencies depend on your merchant configuration; start with USD and contact support for more.

On success: 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.

Yes. We retry with exponential backoff until a 2xx is received or attempts are exhausted. Ensure idempotency to avoid double-processing.

Use the correct HTTP method for each endpoint. For creating a payment intent, use POST.

Prepare the JSON body, compute 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.

If enabled for your merchant, refunds are exposed via separate endpoints. Contact support to enable and obtain documentation.