Request Signature (X-Signature)
Request signature is mandatory for the Money Transfer and International Transfer APIs listed in APIs That Require a Signature. Complete the setup on this page before you go live.
A request signature proves that a request came from you and that its parameters were not changed on the way to Flip. You sign every request with your private key. Flip checks the signature with the public key you registered. If the signature does not match the parameters Flip receives, Flip rejects the request with HTTP 403 Forbidden.
How It Works
- You generate an RSA 2048-bit key pair: a private key and a public key.
- You send the public key to Flip by email. Flip registers it to your account and enables the signature feature.
- For every request, you build a signature payload from the request parameters, sign it with your private key (
sha256WithRSAEncryption), and Base64-encode the result. - You send the Base64 string in the
X-Signatureheader. - Flip rebuilds the same payload from the parameters it receives and verifies your signature with your public key.
Signature is verified in both Sandbox and Production once the public key for that environment is registered.
APIs That Require a Signature
Once signature is enabled for your account, include X-Signature on every request to these APIs, including GET requests.
Money Transfer
| API | Method | Endpoint |
|---|---|---|
| Bank Account Inquiry | POST | /v2/disbursement/bank-account-inquiry |
| City List | GET | /v2/disbursement/city-list |
| Country List | GET | /v2/disbursement/country-list |
| City and Country List | GET | /v2/disbursement/city-country-list |
| Create Disbursement | POST | /v3/disbursement |
| Get All Disbursement | GET | /v3/disbursement |
| Get Disbursement | GET | /v3/get-disbursement |
| Create Special Money Transfer | POST | /v3/special-disbursement |
International Transfer
| API | Method | Endpoint |
|---|---|---|
| Get Exchange Rates | GET | /v2/international-disbursement/exchange-rates |
| Get Form Data | GET | /v2/international-disbursement/form-data |
| Get International Transfer | GET | /v2/international-disbursement/{transaction_id} |
| Get All International Transfer | GET | /v2/international-disbursement |
| Create C2C / C2B International Transfer | POST | /v2/international-disbursement |
| Create B2C / B2B International Transfer | POST | /v2/international-disbursement/create-with-attachment |
The two API families encode the signature payload slightly differently. See Encoding Differences Between API Families in Step 3, and pass the right mode to the code samples.
Step 1: Generate Your Private and Public Key
For Sandbox testing you can generate a test key pair in the Signature Simulator and download both .pem files without a terminal. For Production, generate the key pair on a machine your team controls, as described below.
Generate the key pair with OpenSSL. OpenSSL is included with macOS and most Linux distributions. On Windows, you can use the OpenSSL that comes with Git for Windows (Git Bash).
Generate keys on a secure machine that your team controls. Do not use online key generators.
1. Generate the private key (RSA 2048-bit)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private_key.pem
2. Extract the public key from the private key
openssl pkey -in private_key.pem -pubout -out public_key.pem
3. Check the result
openssl pkey -in private_key.pem -noout -text | head -1
# Private-Key: (2048 bit, 2 primes)
cat public_key.pem
You now have two files:
| File | Starts with | What to do with it |
|---|---|---|
private_key.pem | -----BEGIN PRIVATE KEY----- | Keep it secret. Store it in your server's secret manager and use it to sign requests. Never share it with anyone, including Flip. |
public_key.pem | -----BEGIN PUBLIC KEY----- | Send it to Flip (see Step 2). It is safe to share. |
The public key looks like this:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjhj9bAWzKsLAMlo6+Bmu
...
-----END PUBLIC KEY-----
Anyone with your private key and your API Secret Key can send valid requests as you. Don't commit it to source control, log it, or send it by email or chat. If you think it has leaked, generate a new key pair and send the new public key to Flip right away.
Step 2: Share Your Public Key With Flip
Send your public key by email to Flip so we can register it and enable the signature feature on your account.
- To: [email protected] or [email protected]
- Subject:
Request setup Public Key and Enable signature feature - Attachment:
public_key.pem
Use this email template:
Subject: Request setup Public Key and Enable signature feature
Hi Flip Team,
We would like to set up our public key and enable the signature feature for our account.
Flip for Business Email : <email account registered on Flip for Business>
Company Name : <your registered company name>
Environment : <Sandbox / Production>
PIC Name : <technical contact name>
PIC Email/Phone : <technical contact email or phone>
Our public key is attached (public_key.pem), and pasted below:
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
Thank you.
- Send only the public key. Never attach
private_key.pem. - Sandbox and Production are set up separately. Name the environment in your email, and send a separate request for each one.
- Flip will reply to confirm when the public key is registered and the signature feature is on. From then on, requests without a valid
X-Signatureare rejected on all the APIs listed above, so update your code (Step 3) for every API you use before you ask us to enable it.
Step 3: Build the Signature Payload
Flip does not sign the raw HTTP body. It signs a JSON string built from the request parameters. Your payload must match Flip's byte for byte, or the signature check fails.
Build the payload like this:
-
Collect the parameters, in this order:
- Query string parameters, in the order they appear in the URL
- Body parameters (
application/x-www-form-urlencodedormultipart/form-datafields), in the order they appear in the body
Headers (such as
idempotency-keyandX-TIMESTAMP) are not part of the payload. File fields (such asattachment_dataon Create B2C / B2B International Transfer) are not part of the payload either; sign only the text fields. -
Use each parameter name only once. Don't send the same parameter twice.
-
Keep the parameters in the order you send them. Do not sort them.
-
Treat every value as a string, exactly as it appears in the request, for example
"10000", not10000. -
Encode as compact JSON:
{"name":"value","name":"value"}, with no spaces or line breaks between elements. Some characters are escaped differently per API family; see the next section. -
If the request has no parameters, the payload is
{}for Money Transfer APIs and[]for International Transfer APIs.
Encoding Differences Between API Families
Most payloads contain only letters, digits, and common punctuation, and then both families produce the same string. The differences only show up in these cases:
| Case | Money Transfer APIs | International Transfer APIs |
|---|---|---|
/ in a value, e.g. Jl. Sudirman 1/2 | Not escaped: "Jl. Sudirman 1/2" | Escaped: "Jl. Sudirman 1\/2" |
Non-ASCII characters, e.g. é, 中, emoji | Kept as UTF-8: "José" | Escaped as \uXXXX (lowercase hex, UTF-16 pairs for emoji): "José" |
&, <, > in a value | Escaped as \u0026, \u003c, \u003e: "Tom \u0026 Jerry" | Not escaped: "Tom & Jerry" |
| Request with no parameters | {} | [] |
| Path parameter | Not applicable | Get International Transfer signs the transaction ID from the URL as id: {"id":"<transaction_id>"} |
The code samples below handle both families: pass the International Transfer mode when you call an International Transfer API.
Payload Examples
- Create Disbursement
- Bank Account Inquiry
- Get Disbursement
- City List (no parameters)
- Get Exchange Rates
- Get International Transfer
- Create International Transfer
- Get All International Transfer (no parameters)
Request:
POST /v3/disbursement
idempotency-key: your-unique-id-12345 # header, not signed
account_number=5465327020&bank_code=bca&amount=10000&remark=testing&recipient_city=391&beneficiary_email=
Signature payload:
{"account_number":"5465327020","bank_code":"bca","amount":"10000","remark":"testing","recipient_city":"391","beneficiary_email":""}
Request:
POST /v2/disbursement/bank-account-inquiry
account_number=5465327020&bank_code=bca&inquiry_key=your-unique-id-12344
Signature payload:
{"account_number":"5465327020","bank_code":"bca","inquiry_key":"your-unique-id-12344"}
Request:
GET /v3/get-disbursement?id=1234567890123456789
Signature payload:
{"id":"1234567890123456789"}
Request:
GET /v2/disbursement/city-list
Signature payload (Money Transfer API):
{}
Request:
GET /v2/international-disbursement/exchange-rates?country_iso_code=SGP&transaction_type=C2C
Signature payload:
{"country_iso_code":"SGP","transaction_type":"C2C"}
Request:
GET /v2/international-disbursement/123456789012345678
Signature payload (the transaction ID from the URL is signed as id):
{"id":"123456789012345678"}
Request:
POST /v2/international-disbursement
idempotency-key: your-unique-id-12345 # header, not signed
amount=1000&source_country=IDN&destination_country=SGP&transaction_type=C2C&beneficiary_full_name=felicia&beneficiary_address=Blk 12/3 Orchard Rd
Signature payload (International Transfer API: / is escaped):
{"amount":"1000","source_country":"IDN","destination_country":"SGP","transaction_type":"C2C","beneficiary_full_name":"felicia","beneficiary_address":"Blk 12\/3 Orchard Rd"}
Request:
GET /v2/international-disbursement
Signature payload (International Transfer API):
[]
Step 4: Sign the Payload and Send the Request
Sign the payload string (UTF-8 bytes) with your private key using RSA PKCS#1 v1.5 with SHA-256 (sha256WithRSAEncryption). Base64-encode the result and send it in the X-Signature header.
Code Samples
Each sample has a function that builds the payload with the rules above (with a switch for the International Transfer encoding) and a function that signs it.
- PHP
- Node.js
- Python
- Go
<?php
/**
* Build the signature payload: query params first, then body params.
* Each name is used once (the first occurrence wins) and every value is a string.
* Set $international to true for the International Transfer APIs.
*/
function buildSignaturePayload(array $queryParams = [], array $bodyParams = [], bool $international = false): string
{
// The "+" operator keeps the first occurrence of a name
$mergedParams = array_map('strval', $queryParams + $bodyParams);
if ($international) {
// International Transfer: default json_encode() output ("/" and non-ASCII are escaped, "[]" when empty)
return json_encode($mergedParams);
}
if (empty($mergedParams)) {
return '{}';
}
// Money Transfer: do not escape "/" or non-ASCII characters; escape &, < and > the way Flip does
$payload = json_encode($mergedParams, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Single quotes keep the backslash, so the output contains the literal text \u0026 etc.
return str_replace(['&', '<', '>'], ['\u0026', '\u003c', '\u003e'], $payload);
}
/**
* Sign the payload with RSA SHA-256 and return the Base64-encoded signature.
*/
function generateSignature(string $privateKeyPem, string $payload): string
{
$privateKey = openssl_pkey_get_private($privateKeyPem);
openssl_sign($payload, $rawSignature, $privateKey, OPENSSL_ALGO_SHA256);
return base64_encode($rawSignature);
}
// Example: Create Disbursement (Money Transfer API)
$bodyParams = [
'account_number' => '5465327020',
'bank_code' => 'bca',
'amount' => '10000',
'remark' => 'testing',
'recipient_city' => '391',
'beneficiary_email' => '',
];
$payload = buildSignaturePayload([], $bodyParams);
$signature = generateSignature(file_get_contents('/secure/path/private_key.pem'), $payload);
$ch = curl_init('https://bigflip.id/big_sandbox_api/v3/disbursement');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($bodyParams), // send the same params, in the same order
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Authorization: Basic ' . base64_encode('YOUR_API_SECRET_KEY:'),
'idempotency-key: your-unique-id-12345',
'X-Signature: ' . $signature,
],
]);
$response = curl_exec($ch);
// Example: Get Exchange Rates (International Transfer API)
$queryParams = ['country_iso_code' => 'SGP', 'transaction_type' => 'C2C'];
$payload = buildSignaturePayload($queryParams, [], true);
// GET https://bigflip.id/big_sandbox_api/v2/international-disbursement/exchange-rates?country_iso_code=SGP&transaction_type=C2C
// with header "X-Signature: " . generateSignature($privateKeyPem, $payload)
const crypto = require('crypto');
const fs = require('fs');
// Money Transfer APIs: "/" and non-ASCII characters stay as they are;
// &, <, > and U+2028/U+2029 are escaped.
function jsonStringMoneyTransfer(value) {
return JSON.stringify(String(value))
.replace(/&/g, '\\u0026')
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
// International Transfer APIs: "/" is escaped as "\/" and every non-ASCII
// character is escaped as \uXXXX (lowercase hex, UTF-16 code units).
function jsonStringInternational(value) {
return JSON.stringify(String(value))
.replace(/\//g, '\\/')
.replace(/[\u0080-]/g, (char) => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'));
}
// Build the signature payload: query params first, then body params.
// Each name is used once (the first occurrence wins).
// Pass { international: true } for the International Transfer APIs.
function buildSignaturePayload(queryParams = {}, bodyParams = {}, { international = false } = {}) {
const encode = international ? jsonStringInternational : jsonStringMoneyTransfer;
const merged = new Map();
for (const params of [queryParams, bodyParams]) {
for (const [key, value] of Object.entries(params)) {
if (!merged.has(key)) merged.set(key, String(value));
}
}
if (merged.size === 0) return international ? '[]' : '{}';
const pairs = [...merged].map(([key, value]) => `${encode(key)}:${encode(value)}`);
return `{${pairs.join(',')}}`;
}
// Sign with RSA SHA-256 (sha256WithRSAEncryption) and return the Base64-encoded signature.
function generateSignature(privateKeyPem, payload) {
return crypto.sign('sha256', Buffer.from(payload, 'utf8'), privateKeyPem).toString('base64');
}
const privateKeyPem = fs.readFileSync('/secure/path/private_key.pem', 'utf8');
// Example: Create Disbursement (Money Transfer API)
const bodyParams = {
account_number: '5465327020',
bank_code: 'bca',
amount: '10000',
remark: 'testing',
recipient_city: '391',
beneficiary_email: '',
};
const payload = buildSignaturePayload({}, bodyParams);
const signature = generateSignature(privateKeyPem, payload);
fetch('https://bigflip.id/big_sandbox_api/v3/disbursement', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from('YOUR_API_SECRET_KEY:').toString('base64'),
'idempotency-key': 'your-unique-id-12345',
'X-Signature': signature,
},
body: new URLSearchParams(bodyParams), // send the same params, in the same order
});
// Example: Get Exchange Rates (International Transfer API)
const queryParams = { country_iso_code: 'SGP', transaction_type: 'C2C' };
const ratesPayload = buildSignaturePayload(queryParams, {}, { international: true });
fetch('https://bigflip.id/big_sandbox_api/v2/international-disbursement/exchange-rates?' + new URLSearchParams(queryParams), {
headers: {
Authorization: 'Basic ' + Buffer.from('YOUR_API_SECRET_KEY:').toString('base64'),
'X-Signature': generateSignature(privateKeyPem, ratesPayload),
},
});
# pip install cryptography requests
import base64
import json
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
# Characters the Money Transfer APIs escape in JSON strings ("/" and non-ASCII characters are not escaped)
MONEY_TRANSFER_ESCAPES = {
"&": "\\u0026",
"<": "\\u003c",
">": "\\u003e",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
}
def build_signature_payload(query_params=None, body_params=None, international=False):
"""Build the signature payload: query params first, then body params.
Each name is used once (the first occurrence wins) and every value is a string.
Pass international=True for the International Transfer APIs.
"""
merged = {}
for params in (query_params or {}, body_params or {}):
for key, value in params.items():
merged.setdefault(key, str(value))
if international:
# International Transfer: "/" and non-ASCII characters are escaped, "[]" when empty
if not merged:
return "[]"
return json.dumps(merged, separators=(",", ":"), ensure_ascii=True).replace("/", "\\/")
payload = json.dumps(merged, separators=(",", ":"), ensure_ascii=False) # "{}" when empty
for character, escaped in MONEY_TRANSFER_ESCAPES.items():
payload = payload.replace(character, escaped)
return payload
def generate_signature(private_key_pem: bytes, payload: str) -> str:
"""Sign with RSA SHA-256 (sha256WithRSAEncryption) and return the Base64-encoded signature."""
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
raw_signature = private_key.sign(payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(raw_signature).decode("ascii")
with open("/secure/path/private_key.pem", "rb") as key_file:
private_key_pem = key_file.read()
# Example: Create Disbursement (Money Transfer API)
body_params = {
"account_number": "5465327020",
"bank_code": "bca",
"amount": "10000",
"remark": "testing",
"recipient_city": "391",
"beneficiary_email": "",
}
payload = build_signature_payload(body_params=body_params)
signature = generate_signature(private_key_pem, payload)
response = requests.post(
"https://bigflip.id/big_sandbox_api/v3/disbursement",
auth=("YOUR_API_SECRET_KEY", ""),
headers={
"idempotency-key": "your-unique-id-12345",
"X-Signature": signature,
},
data=body_params, # sent as application/x-www-form-urlencoded, in the same order
)
# Example: Get Exchange Rates (International Transfer API)
query_params = {"country_iso_code": "SGP", "transaction_type": "C2C"}
rates_payload = build_signature_payload(query_params=query_params, international=True)
response = requests.get(
"https://bigflip.id/big_sandbox_api/v2/international-disbursement/exchange-rates",
auth=("YOUR_API_SECRET_KEY", ""),
headers={"X-Signature": generate_signature(private_key_pem, rates_payload)},
params=query_params, # sent in the same order
)
package flipsignature
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"strings"
"unicode/utf16"
)
// APIFamily selects the payload encoding rules.
type APIFamily int
const (
// MoneyTransfer covers the Money Transfer APIs (bank account inquiry, city/country lists, v3 disbursement).
MoneyTransfer APIFamily = iota
// InternationalTransfer covers the International Transfer APIs.
InternationalTransfer
)
// RequestParam is one request parameter.
// Params are kept in a slice, not a map, because Go maps do not keep insertion order.
type RequestParam struct {
Name string
Value string
}
// encodeJSONString encodes one name or value as a JSON string the way the chosen API family expects.
func encodeJSONString(text string, apiFamily APIFamily) (string, error) {
if apiFamily == MoneyTransfer {
// encoding/json gives exactly the escaping the Money Transfer APIs expect
encodedBytes, marshalErr := json.Marshal(text)
return string(encodedBytes), marshalErr
}
// International Transfer: keep &, < and > as they are, then escape "/" and every non-ASCII character
var encodedBuffer bytes.Buffer
jsonEncoder := json.NewEncoder(&encodedBuffer)
jsonEncoder.SetEscapeHTML(false)
if encodeErr := jsonEncoder.Encode(text); encodeErr != nil {
return "", encodeErr
}
var escaped strings.Builder
for _, character := range strings.TrimRight(encodedBuffer.String(), "\n") {
switch {
case character == '/':
escaped.WriteString(`\/`)
case character > 0x7f:
for _, codeUnit := range utf16.Encode([]rune{character}) {
fmt.Fprintf(&escaped, `\u%04x`, codeUnit)
}
default:
escaped.WriteRune(character)
}
}
return escaped.String(), nil
}
// BuildSignaturePayload builds the signature payload: query params first, then body params.
// Each name is used once (the first occurrence wins).
func BuildSignaturePayload(queryParams, bodyParams []RequestParam, apiFamily APIFamily) (string, error) {
seenParamNames := map[string]bool{}
encodedPairs := []string{}
for _, paramGroup := range [][]RequestParam{queryParams, bodyParams} {
for _, requestParam := range paramGroup {
if seenParamNames[requestParam.Name] {
continue // a repeated name is ignored
}
seenParamNames[requestParam.Name] = true
encodedName, nameErr := encodeJSONString(requestParam.Name, apiFamily)
if nameErr != nil {
return "", nameErr
}
encodedValue, valueErr := encodeJSONString(requestParam.Value, apiFamily)
if valueErr != nil {
return "", valueErr
}
encodedPairs = append(encodedPairs, encodedName+":"+encodedValue)
}
}
if len(encodedPairs) == 0 {
if apiFamily == InternationalTransfer {
return "[]", nil
}
return "{}", nil
}
return "{" + strings.Join(encodedPairs, ",") + "}", nil
}
// GenerateSignature signs the payload with RSA SHA-256 (sha256WithRSAEncryption)
// and returns the Base64-encoded signature. It expects a PKCS#8 "BEGIN PRIVATE KEY" PEM.
func GenerateSignature(privateKeyPem []byte, signaturePayload string) (string, error) {
pemBlock, _ := pem.Decode(privateKeyPem)
if pemBlock == nil {
return "", errors.New("invalid private key PEM")
}
parsedKey, parseErr := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
if parseErr != nil {
return "", parseErr
}
rsaPrivateKey, isRsaKey := parsedKey.(*rsa.PrivateKey)
if !isRsaKey {
return "", errors.New("private key is not an RSA key")
}
payloadDigest := sha256.Sum256([]byte(signaturePayload))
signatureBytes, signErr := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, payloadDigest[:])
if signErr != nil {
return "", signErr
}
return base64.StdEncoding.EncodeToString(signatureBytes), nil
}
// Example: Create Disbursement (Money Transfer API)
//
// bodyParams := []RequestParam{
// {"account_number", "5465327020"},
// {"bank_code", "bca"},
// {"amount", "10000"},
// {"remark", "testing"},
// {"recipient_city", "391"},
// {"beneficiary_email", ""},
// }
// signaturePayload, err := BuildSignaturePayload(nil, bodyParams, MoneyTransfer)
// signature, err := GenerateSignature(privateKeyPem, signaturePayload)
// // Send bodyParams form-encoded in the same order, with header "X-Signature: <signature>"
//
// Example: Get Exchange Rates (International Transfer API)
//
// queryParams := []RequestParam{{"country_iso_code", "SGP"}, {"transaction_type", "C2C"}}
// signaturePayload, err := BuildSignaturePayload(queryParams, nil, InternationalTransfer)
// // GET /v2/international-disbursement/exchange-rates?country_iso_code=SGP&transaction_type=C2C
Sample Requests With Signature
- Money Transfer: Create Disbursement
- International Transfer: Get Exchange Rates
curl -X POST 'https://bigflip.id/big_sandbox_api/v3/disbursement' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Accept: application/json; charset=UTF-8' \
-H 'Authorization: Basic <Base64(Your-API-SecretKey + :)>' \
-H 'idempotency-key: your-unique-id-12345' \
-H 'X-Signature: <Your-Generated-Signature>' \
-d 'account_number=5465327020' \
-d 'bank_code=bca' \
-d 'amount=10000' \
-d 'remark=testing' \
-d 'recipient_city=391' \
-d 'beneficiary_email='
The signature payload for this request is:
{"account_number":"5465327020","bank_code":"bca","amount":"10000","remark":"testing","recipient_city":"391","beneficiary_email":""}
curl -X GET 'https://bigflip.id/big_sandbox_api/v2/international-disbursement/exchange-rates?country_iso_code=SGP&transaction_type=C2C' \
-H 'Accept: application/json; charset=UTF-8' \
-H 'Authorization: Basic <Base64(Your-API-SecretKey + :)>' \
-H 'X-Signature: <Your-Generated-Signature>'
The signature payload for this request is:
{"country_iso_code":"SGP","transaction_type":"C2C"}
Optional: X-TIMESTAMP Header
X-TIMESTAMP is not part of the signature, and you don't need it to use signatures. You can send it on the Create APIs that list it in the API reference so that Flip doesn't process a request that was delayed: Flip rejects the request if the timestamp is more than 60 seconds old. If you leave the header out, this check is skipped.
- Format: ISO 8601 with a timezone offset, for example
2026-10-06T10:15:30+07:00 - Clock: keep your server clock in sync (for example with NTP), or valid requests may be rejected as stale
- Error: a stale request fails with the message
Request sent at <timestamp>, and marked as stale
-H 'X-TIMESTAMP: 2026-10-06T10:15:30+07:00'
Test Your Signature
Signature Simulator
The quickest check is the Signature Simulator. It walks you through the whole flow in the browser:
- Keys: generate a test key pair, or paste your own private key.
- Request: pick the API and enter the parameters in the order you send them. The simulator shows the exact payload string.
- Sign: get the
X-Signaturefor that payload and a ready-to-send cURL command. - Verify: paste your public key and the signature your own code produced. The simulator tells you whether Flip would accept it, and if not, which common mistake your payload matches.
Everything runs in your browser. Nothing you enter is sent to Flip or anywhere else. Use a test key for signing in the simulator; keep your Production private key on your servers.
OpenSSL
You can also verify with OpenSSL. Use your code to print the payload and the signature, then run:
# 1. Save the exact payload your code generated (printf adds no trailing newline)
printf '%s' '{"account_number":"5465327020","bank_code":"bca","amount":"10000","remark":"testing","recipient_city":"391","beneficiary_email":""}' > payload.txt
# 2. Save the Base64 signature your code generated, then decode it
echo '<Your-Generated-Signature>' | openssl base64 -d -A > signature.bin
# 3. Verify it with your public key
openssl dgst -sha256 -verify public_key.pem -signature signature.bin payload.txt
# Verified OK
You can also make a reference signature with OpenSSL alone and compare it with your code's output. RSA PKCS#1 v1.5 signatures are deterministic, so the same key and payload always give the same signature:
printf '%s' '{"account_number":"5465327020","bank_code":"bca","amount":"10000","remark":"testing","recipient_city":"391","beneficiary_email":""}' \
| openssl dgst -sha256 -sign private_key.pem | openssl base64 -A
Troubleshooting
If Flip returns HTTP 403 Forbidden with the message You are not allowed to perform this action. after signature is enabled, check these common causes:
| Cause | Fix |
|---|---|
Missing X-Signature header | Send the header on every request to the APIs listed above, including GET requests and requests without parameters. |
| Parameters sorted or reordered | Build the payload in the exact order you send the parameters. |
Payload has spaces, e.g. {"a": "b"} | Use compact JSON with no spaces: {"a":"b"}. |
| Wrong rules for the API family | Money Transfer and International Transfer APIs escape /, non-ASCII and &<> differently. See Encoding Differences and use the matching mode in your code. |
/ escaped as \/ on a Money Transfer API | Don't escape /. In PHP, pass JSON_UNESCAPED_SLASHES to json_encode(). |
/ not escaped on an International Transfer API | Escape / as \/ (PHP's default json_encode() does this). |
| Numbers signed as JSON numbers | Every value must be a string: "amount":"10000". |
| Value in payload differs from value sent | Sign exactly the value you send. Don't trim, reformat, or round it after signing. |
| Headers included in the payload | Don't sign headers such as idempotency-key or X-TIMESTAMP. Sign only query and body parameters. |
| File field included in the payload | Don't sign file uploads such as attachment_data. Sign only the text fields. |
| Get International Transfer signed with an empty payload | The transaction ID in the URL is signed: {"id":"<transaction_id>"}. |
| Same parameter sent twice | Send each parameter name once. |
| Request without parameters signed as an empty string | Sign {} for a Money Transfer API and [] for an International Transfer API. |
| Wrong key pair | Make sure the public key registered with Flip comes from the private key you sign with. Check the environment: Sandbox and Production keys are registered separately. |
| Signature Base64 is URL-safe or has line breaks | Use standard Base64 (+ and /) on one line. |
If you still get errors, email [email protected] with your Flip for Business email account, the endpoint, the request time, the payload string you signed, and the signature you sent. Never include your private key.
Rotating Your Key
To replace your key pair, for example on a schedule or after a suspected leak:
- Generate a new key pair (Step 1).
- Email the new public key to [email protected] or [email protected] with the subject
Request setup Public Key and Enable signature feature, and say in the email that this is a key rotation. - Agree on a switch-over time with the Flip team. When the new public key is active, signatures made with the old private key are rejected, so start signing with the new private key at the agreed time.
- Securely delete the old private key.