How does SekaliPay API authentication work?
Every request carries your API key in the X-API-Key header. The rest depends on the account mode: IP Whitelist (default) accepts requests from registered IPs and signs only POST requests, while Enhanced HMAC signs every request together with X-Timestamp and suits servers without a fixed IP.
This page was checked against the API code as of October 16, 2026.
What is the difference between the API key and the secret key?#
| Key | Used for | Sent in requests? |
|---|---|---|
API key (mk_...) | Identifies your merchant account. | Yes, in the X-API-Key header of every request. |
Secret key (sk_...) | Signing requests and verifying webhooks. | Never. It is only used to compute HMACs on your server. |
The secret key belongs on the server only
The secret key is shown in the dashboard after PIN verification. Store it in an environment variable or secret manager, never in a mobile app, browser JavaScript, or a repository. If it leaks, generate new keys under API Settings, API Keys: the API key and secret key are replaced together and the old ones stop working immediately.
Which mode should I use?#
| IP Whitelist (default) | Enhanced HMAC | |
|---|---|---|
| Best for | Servers with a fixed public IP: VPS, office servers. | Changing IPs: Cloudflare Workers, AWS Lambda, Vercel. |
| What is checked | The sender IP must be registered. | The signature and X-Timestamp. The IP is not checked. |
| GET requests | X-API-Key only. | X-API-Key, X-Signature, X-Timestamp. |
| POST requests | X-API-Key and X-Signature. | X-API-Key, X-Signature, X-Timestamp. |
| What is signed | The raw JSON body. | METHOD|PATH|PARAMS|TIMESTAMP. |
| How to enable | Add at least one IP under API Settings, Mode & access. | Turn the IP whitelist off on the same page. |
The mode applies to the whole account and cannot be mixed per request. Rate limits are the same in both modes.
How do I sign requests in IP Whitelist mode?#
Compute an HMAC-SHA256 over the JSON body exactly as sent, keyed with your secret key, and send the lowercase hex result in X-Signature. GET requests need no signature.
X-Signature = hex( HMAC-SHA256( secret_key, raw_request_body ) )$body = json_encode([
'merchant_ref_id' => 'ORDER-2026-00184',
'amount' => 150000,
'payment_code' => 'QRIS',
'customer_name' => 'Budi Santoso',
'customer_email' => '[email protected]',
'customer_phone' => '081234567890',
'callback_url' => 'https://toko-anda.id/webhook/sekalipay',
'return_url' => 'https://toko-anda.id/checkout/selesai',
]);
$signature = hash_hmac('sha256', $body, getenv('SEKALIPAY_SECRET_KEY'));
$ch = curl_init('https://sekalipay.com/api/v1/gateway/payment');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-API-Key: ' . getenv('SEKALIPAY_API_KEY'),
'X-Signature: ' . $signature,
],
]);
$response = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);import { createHmac } from "node:crypto";
const body = JSON.stringify({
"merchant_ref_id": "ORDER-2026-00184",
"amount": 150000,
"payment_code": "QRIS",
"customer_name": "Budi Santoso",
"customer_email": "[email protected]",
"customer_phone": "081234567890",
"callback_url": "https://toko-anda.id/webhook/sekalipay",
"return_url": "https://toko-anda.id/checkout/selesai"
});
const signature = createHmac("sha256", process.env.SEKALIPAY_SECRET_KEY)
.update(body)
.digest("hex");
const res = await fetch("https://sekalipay.com/api/v1/gateway/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-API-Key": process.env.SEKALIPAY_API_KEY,
"X-Signature": signature,
},
body,
});
const result = await res.json();BODY='{"merchant_ref_id":"ORDER-2026-00184","amount":150000,"payment_code":"QRIS","customer_name":"Budi Santoso","customer_email":"[email protected]","customer_phone":"081234567890","callback_url":"https://toko-anda.id/webhook/sekalipay","return_url":"https://toko-anda.id/checkout/selesai"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SEKALIPAY_SECRET_KEY" | sed 's/^.* //')
curl -X POST https://sekalipay.com/api/v1/gateway/payment \
-H "X-API-Key: $SEKALIPAY_API_KEY" \
-H "X-Signature: $SIGNATURE" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-raw "$BODY"The most common mistake
Signing one string and sending another, for example signing the output of json_encode and then letting the HTTP client encode the same array again. A different key order, whitespace, or \/ escaping breaks the signature. Keep the body as a string, sign that string, send that string.
Requests from unregistered IPs are rejected with 403 IP_NOT_WHITELISTED, and the error response includes your_ip: the IP we saw, ready to register.
How do I sign requests in Enhanced HMAC mode?#
Every request, including GET, carries X-Signature and X-Timestamp. What gets signed is four parts joined with |:
string_to_sign = METHOD + "|" + PATH + "|" + PARAMS + "|" + TIMESTAMP
X-Signature = hex( HMAC-SHA256( secret_key, string_to_sign ) )
POST|api/v1/gateway/payment|amount=50000&merchant_ref_id=ORDER-001&payment_code=QRIS|1791446400
GET|api/v1/gateway/payment/ORDER-001||1791446400
GET|api/v1/gateway/payment-methods||1791446400| Part | Content |
|---|---|
METHOD | Uppercase: GET, POST. |
PATH | No leading slash and no query string: api/v1/gateway/payment. |
PARAMS | Every parameter (JSON body fields and query string), top-level keys sorted A to Z, then encoded exactly like PHP http_build_query(). Empty for a GET without parameters. |
TIMESTAMP | Unix time in seconds, the same value as the X-Timestamp header. Must be within 300 seconds of server time. |
PARAMS encoding rules (important outside PHP)
Spaces become +; only A-Z a-z 0-9 - _ . stay as they are, everything else becomes %XX (uppercase hex, UTF-8 bytes). null values and empty arrays are left out; true/false become 1/0. Only top-level keys are sorted; nested objects such as metadata keep the order you send and become metadata%5Bkey%5D=value. The server trims whitespace from both ends of every string and drops empty strings before verifying, so do the same. Both examples below already handle it.
function phpUrlencode(value) {
return encodeURIComponent(value)
.replace(/[!'()*~]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase())
.replace(/%20/g, "+");
}
function buildParams(params) {
const parts = [];
const add = (prefix, value) => {
if (typeof value === "string") value = value.trim();
if (value === null || value === undefined || value === "") return;
if (typeof value === "object") {
for (const key of Object.keys(value)) add(prefix + "%5B" + phpUrlencode(key) + "%5D", value[key]);
return;
}
if (value === true) value = "1";
else if (value === false) value = "0";
parts.push(prefix + "=" + phpUrlencode(String(value)));
};
for (const key of Object.keys(params).sort()) add(phpUrlencode(key), params[key]);
return parts.join("&");
}
async function hmacSha256Hex(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function sekalipay(method, path, payload, { apiKey, secretKey }) {
const timestamp = Math.floor(Date.now() / 1000);
const stringToSign = method + "|" + path + "|" + buildParams(payload ?? {}) + "|" + timestamp;
const signature = await hmacSha256Hex(secretKey, stringToSign);
const res = await fetch("https://sekalipay.com/" + path, {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-API-Key": apiKey,
"X-Signature": signature,
"X-Timestamp": String(timestamp),
},
body: method === "GET" ? undefined : JSON.stringify(payload),
});
return { status: res.status, body: await res.json() };
}
const keys = { apiKey: SEKALIPAY_API_KEY, secretKey: SEKALIPAY_SECRET_KEY };
await sekalipay("POST", "api/v1/gateway/payment", {
merchant_ref_id: "ORDER-2026-00184",
amount: 150000,
payment_code: "QRIS",
customer_name: "Budi Santoso",
customer_email: "[email protected]",
customer_phone: "081234567890",
}, keys);
await sekalipay("GET", "api/v1/gateway/payment/ORDER-2026-00184", null, keys);function sekalipay(string $method, string $path, array $payload = []): array
{
$timestamp = time();
$params = $payload;
array_walk_recursive($params, function (&$value) {
if (is_string($value)) {
$value = trim($value) === '' ? null : trim($value);
}
});
ksort($params);
$stringToSign = $method . '|' . $path . '|' . http_build_query($params) . '|' . $timestamp;
$signature = hash_hmac('sha256', $stringToSign, getenv('SEKALIPAY_SECRET_KEY'));
$ch = curl_init('https://sekalipay.com/' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-API-Key: ' . getenv('SEKALIPAY_API_KEY'),
'X-Signature: ' . $signature,
'X-Timestamp: ' . $timestamp,
],
]);
if ($method !== 'GET') {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = curl_exec($ch);
return [curl_getinfo($ch, CURLINFO_HTTP_CODE), json_decode($body, true)];
}
sekalipay('POST', 'api/v1/gateway/payment', [
'merchant_ref_id' => 'ORDER-2026-00184',
'amount' => 150000,
'payment_code' => 'QRIS',
'customer_name' => 'Budi Santoso',
'customer_email' => '[email protected]',
'customer_phone' => '081234567890',
]);
sekalipay('GET', 'api/v1/gateway/payment/ORDER-2026-00184');A server clock that drifts more than 300 seconds gets every request rejected with 401 TIMESTAMP_EXPIRED. The error response includes server_time so you can correct the clock.
{
"success": false,
"message": "Request timestamp expired or invalid",
"error_code": "TIMESTAMP_EXPIRED",
"data": {
"server_time": 1791446812,
"your_timestamp": 1791446400,
"max_difference": 300
}
}How do I know my signature code is right?#
Run your code with the inputs below. The result must match exactly before you call the API. These keys are examples only and do not work against the API.
secret_key : sk_test_contoh_saja
body : {"merchant_ref_id":"ORDER-001","amount":50000,"payment_code":"QRIS"}
X-Signature : f8fbd182f2c14ae0b8a231f0687e4070f095fe1881374f09f6a4634cf021fbfasecret_key : sk_test_contoh_saja
X-Timestamp : 1791446400
string_to_sign : POST|api/v1/gateway/payment|amount=50000&merchant_ref_id=ORDER-001&payment_code=QRIS|1791446400
X-Signature : 46e51144b78aefcf73342111a465ddd6aede25abdcfbfdbb4c6cf1b792116402Which authentication errors can occur?#
| HTTP | Code | Meaning |
|---|---|---|
| 401 | MISSING_API_KEY | The X-API-Key header was not sent. |
| 401 | INVALID_API_KEY | The API key is wrong, has been replaced, or the account is not active. |
| 403 | IP_WHITELIST_NOT_CONFIGURED | IP Whitelist mode is on but no IP is registered. Add an IP, or turn the whitelist off to use Enhanced HMAC. |
| 403 | IP_NOT_WHITELISTED | The sender IP is not registered. Register the IP shown in your_ip. |
| 401 | MISSING_SIGNATURE | A POST request in IP Whitelist mode without an X-Signature header. |
| 401 | INVALID_SIGNATURE | The body signature does not match. Sign exactly the bytes you send. |
| 401 | MISSING_HMAC_SIGNATURE | Enhanced HMAC mode is on but X-Signature or X-Timestamp was not sent. |
| 401 | TIMESTAMP_EXPIRED | X-Timestamp is more than 300 seconds away from server time. Compare it with data.server_time. |
| 401 | INVALID_HMAC_SIGNATURE | The Enhanced HMAC signature does not match. Check PATH, the order and encoding of PARAMS, and the timestamp. |
Ready to use your own keys?
Sign in to the merchant dashboard to copy your API key and secret key, set the access mode and webhook URL, and download a Postman collection with your account's keys.
Sign in to the dashboard
