To generate signatures, you need an RSA private and public key pair. You can generate these keys yourself or use a third-party tool like cryptotools.net.
Required Parameters
To create a valid signature, include the following parameters:
| Parameter | Description |
|---|---|
| METHOD | HTTP request method in uppercase (e.g., POST, GET). |
| URL | Request path only, starting with / (e.g., /api/checkout). Do not include the scheme, host, or query string. The path is case-sensitive. A trailing slash is ignored: /api/balance/ is signed and verified as /api/balance. |
| PAYLOAD | SHA-256 hash of the exact bytes of the request body you send, as a lowercase hexadecimal string. For GET requests (no body), hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. |
| TIMESTAMP | Current timestamp in ISO8601 format with an explicit offset (e.g., +07:00 or Z), within ±5 minutes of server time. See Timestamp Rules. |
Concatenating Parameters
The signature string (stringToSign) is created by joining the parameters with : in this format:
stringToSign = {METHOD}:{URL}:{PAYLOAD}:{TIMESTAMP}
Replace placeholders with actual values:
{METHOD}→ HTTP request method in uppercase.{URL}→ Request path only (e.g.,/api/checkout). No query string. Case-sensitive. No trailing slash.{PAYLOAD}→ Lowercase hex SHA-256 of the exact request body bytes. ForGET, use the hash of the empty string (see above).{TIMESTAMP}→ Current timestamp in ISO8601 format with an explicit offset. Use the exact same value in theX-TIMESTAMPheader.
Hash the Exact Body You Send
The server hashes the raw request body byte for byte, as received. It does not parse or re-serialize the JSON.
- Serialize the body to a string once. Hash that string, then send that same string as the request body.
- Do not hash a re-serialized copy (for example,
JSON.stringify(JSON.parse(body)),json_encode(json_decode($body)), orjson.dumps(json.loads(body))). Key order, whitespace, slash escaping (\/), Unicode escaping, and number formatting can change, and the signature will not match. - Do not let your HTTP client re-encode the body after you sign it (for example, by passing an object instead of the signed string).
Signing Algorithm
Sign stringToSign (UTF-8 bytes) with your private key using RSA with SHA-256 and PKCS#1 v1.5 padding (RSASSA-PKCS1-v1_5, also called RSA-SHA256 or SHA256withRSA). Do not use PSS padding. Send the result Base64-encoded in the X-SIGNATURE header.
const crypto = require('crypto');
const fs = require('fs');
const httpMethod = 'POST';
const endpointUrl = '/api/create/qris'; // path only, no query string
const timestamp = new Date().toISOString(); // e.g. 2024-12-16T05:11:14.123Z
// Serialize ONCE. Send this exact string as the request body.
// For GET requests, use: const body = '';
const body = JSON.stringify({
merchant_transaction_id: 'ORDER-001',
amount: '50000',
currency: 'IDR',
customer_name: 'John Doe',
product_name: 'Test Order',
});
const payloadHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const stringToSign = `${httpMethod}:${endpointUrl}:${payloadHash}:${timestamp}`;
console.log('String to Sign:', stringToSign);
// RSA-SHA256 with PKCS#1 v1.5 padding (Node.js default for RSA keys)
const privateKey = fs.readFileSync('./path-to-private-key-file.pem');
const signature = crypto.sign('sha256', Buffer.from(stringToSign, 'utf8'), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
}).toString('base64');
console.log('Generated Signature:', signature);
// Send `body` (unchanged) with headers X-TIMESTAMP: timestamp, X-SIGNATURE: signature
<?php
$httpMethod = 'POST';
$endpointUrl = '/api/create/qris'; // path only, no query string
$timestamp = date('c'); // e.g. 2026-01-15T14:30:00+07:00
// Serialize ONCE. Send this exact string as the request body.
// For GET requests, use: $body = '';
$body = json_encode([
'merchant_transaction_id' => 'ORDER-001',
'amount' => '50000',
'currency' => 'IDR',
'customer_name' => 'John Doe',
'product_name' => 'Test Order',
]);
$payloadHash = hash('sha256', $body); // lowercase hex
$stringToSign = "{$httpMethod}:{$endpointUrl}:{$payloadHash}:{$timestamp}";
echo "String to Sign: {$stringToSign}\n";
// RSA-SHA256 with PKCS#1 v1.5 padding
$privateKey = openssl_pkey_get_private('file://./path-to-private-key-file.pem');
openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$signatureB64 = base64_encode($signature);
echo "Generated Signature: {$signatureB64}\n";
// Send $body (unchanged) with headers X-TIMESTAMP: $timestamp, X-SIGNATURE: $signatureB64
import hashlib
import base64
import json
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
http_method = 'POST'
endpoint_url = '/api/create/qris' # path only, no query string
timestamp = datetime.now(timezone.utc).isoformat() # e.g. 2024-12-16T05:11:14.123456+00:00
# Serialize ONCE. Send these exact bytes as the request body.
# For GET requests, use: body = b''
body = json.dumps({
'merchant_transaction_id': 'ORDER-001',
'amount': '50000',
'currency': 'IDR',
'customer_name': 'John Doe',
'product_name': 'Test Order',
}, separators=(',', ':')).encode('utf-8')
payload_hash = hashlib.sha256(body).hexdigest()
string_to_sign = f"{http_method}:{endpoint_url}:{payload_hash}:{timestamp}"
print(f"String to Sign: {string_to_sign}")
# RSA-SHA256 with PKCS#1 v1.5 padding
with open('./path-to-private-key-file.pem', 'rb') as key_file:
private_key = serialization.load_pem_private_key(key_file.read(), password=None)
signature = private_key.sign(
string_to_sign.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
signature_b64 = base64.b64encode(signature).decode()
print(f"Generated Signature: {signature_b64}")
# Send `body` (unchanged, e.g. requests.post(url, data=body, ...)) with X-TIMESTAMP and X-SIGNATURE
- Ensure the private key is securely stored and not exposed in public repositories.
- The
TIMESTAMPmust be generated fresh for every request in ISO8601 format with an explicit offset. Keep it within ±5 minutes of server time. Requests outside this window may be rejected (see Timestamp Rules).
Test Vectors
Use these values to check your implementation. RSA PKCS#1 v1.5 signatures are deterministic, so with the test key below your code must produce exactly the same hash, stringToSign, and signature. These vectors were verified with OpenSSL. They are for offline verification only. Do not send them to the API: the fixed timestamp is outside the allowed window.
Test private key (PEM)
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSH6vw8qTE2hBN
gzCPOKk0VfpJg3CBXFC0SmByTnUXJS8sQDqBBga8essnUGJgwpTVAiQ+G/H/UkfK
pPVxieAFO06JPMK2M7thj1gUgH6APpN2l/M4G3uO04wElFNpJKE+kDwZ8pQ+kWRf
DTSbNkp3iOgKogf3pKaPkC6AeDqeRe233aYmMwFaMecSMRSfjVZ6XOxZIONLStgv
t7FsxJssU9qSYkKxmFUZpSZLtsK/KqwfX3bs/Q0ueb9MTadkcAFWsZ9ll7CttgkQ
dMESpQoP1EeOuYTZqhSO+WvEokvjDNtz6vkZMnr3o7MR3RuHJ3DlelOHQHm0Q+kU
JFSVProzAgMBAAECggEAJm66kUigUI0eHD9Zm2iesj7jl5yd8FtFO6S6/U1SZ1ha
x3o7+St0AfdnvxoZ1F7iwA8zN9T+r1eKYAtKBWlVez98vudJKfBOXqrMTnuuJ6DK
cg+a4fFM6TPSUWgb1YhoobvZ8lkHOmZ3eFc+/wCwICr/XiqC0I3iFTIm/0J+XkJR
MVDqfFsdNWD7ASj6K4Sl/UauN/+VC8Dkfy2j4GlZ4XcIOPYNUfU+55mqhNc+2aNK
xZUASqsggeLtgopXQoBeXFnRqzMbky63PMu0E88mdbozV62fgWTfLwpcpV0ICcbB
r4S73D1Tk9nlD067Oxwf5c/kw+M9dcbGPvTvqSigeQKBgQDwieRX0e3VriaK2hf9
2lyWR0SmW3NxDBZEilRWVHFNMBK1RyNACvpirdjjLktLOu2zWh0efnhltZm0y1FK
vGEbVM+4zsNPKEUpoI2IXuZqKu/obENyxln08Kcnm50h8Jtxx3pj5maygB/2umO/
iOybSMB1GOQZT6KLRFCpqN7OHwKBgQDfoUvlStFdH4PH+xq0eitFzqmSfyUKo7XS
HLmigowxKHbXr0YjLTGAceTG356D8tGlkYRs0BZCQ3mFgdJmlzHMQefAgpwciJ3n
lZlAcY5abJO07ReV7g8qiJGdwaloazfeXAYR67e7tpdto4dmgPzmhyOdIK/v3ZEo
Cr7U+OIpbQKBgQCdPecxqaBKYW/8sRmh7w87hhdQkNPkgLxFzg/X55exl82DB5/5
i7OmsnLoAClkYn9lEXGbr51RD+gNEiRwMLliPo0UCMYQhwi/b59WGIOTv8HZNHzb
GONRhcgf0lBkEX/sXzSY/dMAuVUr2yi92uqhkGuPoNfr2+0sEVqRrj0IjQKBgQCe
SrjDG5tolCcehdJYT2WT6v3CsXbRuiG4ERchPdDn9uFwOCOoQJQafAPY9W5prZXR
4VfgAcbPrp008Diy33CjST821/ySge1rgEvS/s4pmVNQLMaWHMTlcXfJN8ynGcWZ
AoBVbQlHuK4lMCxmVQUuVBfUX8/JNIdL1ZdQfr7vXQKBgDBrw5daXitkNJ51leIz
pfZaP8aHHKs9Kl/DIL7prTMx8oV3Mz5fa1oLvzJkGhl0k3SIt7LKxOSxJjYOPZM2
K8fi8fGAIgBOPwpyqvDHhaQ2Jfj0uwD9+8Xy9LDzpgxN17Ru3zhyojYNXU/dCpl+
KRG7MjWhT0CuKEk1WE+LEiVC
-----END PRIVATE KEY-----
Test public key (PEM)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0h+r8PKkxNoQTYMwjzip
NFX6SYNwgVxQtEpgck51FyUvLEA6gQYGvHrLJ1BiYMKU1QIkPhvx/1JHyqT1cYng
BTtOiTzCtjO7YY9YFIB+gD6TdpfzOBt7jtOMBJRTaSShPpA8GfKUPpFkXw00mzZK
d4joCqIH96Smj5AugHg6nkXtt92mJjMBWjHnEjEUn41WelzsWSDjS0rYL7exbMSb
LFPakmJCsZhVGaUmS7bCvyqsH1927P0NLnm/TE2nZHABVrGfZZewrbYJEHTBEqUK
D9RHjrmE2aoUjvlrxKJL4wzbc+r5GTJ696OzEd0bhydw5XpTh0B5tEPpFCRUlT66
MwIDAQAB
-----END PUBLIC KEY-----
Vector 1: POST with JSON body
Body (exact bytes, 128 bytes, no trailing newline):
{"merchant_transaction_id":"ORDER-001","amount":"50000","currency":"IDR","customer_name":"John Doe","product_name":"Test Order"}
| Field | Value |
|---|---|
| METHOD | POST |
| URL | /api/create/qris |
| TIMESTAMP | 2026-01-15T14:30:00+07:00 |
| SHA-256 (body) | fbe2d3b8024638a94ab7104abc45489faa5af28eb3d94d59457b6eab37346113 |
stringToSign:
POST:/api/create/qris:fbe2d3b8024638a94ab7104abc45489faa5af28eb3d94d59457b6eab37346113:2026-01-15T14:30:00+07:00
X-SIGNATURE:
D+2aHQbhBxIDXlSMPL62QdIonsjQvRT5QjMD86ctgmWzLSNsFfYJNC+9Q7tXBjOeKTaaOQWGta3bnPDN0E1TxE/wJdzn5nmjc0SKdLZ9O2yvR77xfYVGPaDcD308tjfcfM6utNZtvXJWUnQaZzWy8cUiMr7pAeI0cqNUaCJa6EpBVzIIpSshwhEMfaO5BWNoz15A7DqA4z0raCaf7S4acmoS/lsDw+6Dli7mZml5JCL6QVu02XXd8+fnNlCep0tL4XYakmgOJnBqV7Vm0XSVQwTb3RK6/AWSt6ESVJjr9dfTtsTT3B9GEGgPwP99spQky/kOaV0H90TkO7tR7s/EjQ==
Vector 2: GET without body
| Field | Value |
|---|---|
| METHOD | GET |
| URL | /api/balance |
| TIMESTAMP | 2026-01-15T14:30:00+07:00 |
| SHA-256 (empty body) | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
stringToSign:
GET:/api/balance:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-01-15T14:30:00+07:00
X-SIGNATURE:
zAa91hW77Y5LHv3lGHVRtv3Z7Jy7wmhbZ/PNzB3Di5ysr4n6H2JMTefpqVy8poj7oZOyCkN3Oy7o1Tzu16L+J+jG8UTOffPKW8L95bBozyh7AggKpQ+bcQuCleNk8FwiOyFNy4z5t6rP3KIj8Ry+O9C7S39bCY/6aCSSEIGrsc5hbTh+yihsZMEgzySFu5M2x654khwxugEz57A2KqDnK1iNLIHCrWFLHJtTzH1WYwBIO6Aw0HbBuxMPahStPr4UHkQJlznyCkzM6LQURgIbcYit5mhTg/gQdOaz4/1fULmLqBtbvmBwuTPQcxr5rMGmnSlxWEVXQcT+wCm3HEpj0Q==
Reproduce with OpenSSL
Save the test private key as test-private.pem and the test public key as test-public.pem, then run:
# Vector 1
printf '%s' '{"merchant_transaction_id":"ORDER-001","amount":"50000","currency":"IDR","customer_name":"John Doe","product_name":"Test Order"}' > body.json
HASH=$(openssl dgst -sha256 -hex body.json | awk '{print $NF}')
printf '%s' "POST:/api/create/qris:${HASH}:2026-01-15T14:30:00+07:00" > string-to-sign.txt
# Sign (RSA-SHA256, PKCS#1 v1.5) and Base64-encode
openssl dgst -sha256 -sign test-private.pem string-to-sign.txt | base64 | tr -d '\n'; echo
# Verify
openssl dgst -sha256 -sign test-private.pem -out sig.bin string-to-sign.txt
openssl dgst -sha256 -verify test-public.pem -signature sig.bin string-to-sign.txt # Verified OK
# Vector 2: hash of the empty string
printf '' | openssl dgst -sha256 -hex
Use printf '%s', not echo: echo adds a trailing newline, which changes the hash.