Skip to main content

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?#

KeyUsed forSent 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 forServers with a fixed public IP: VPS, office servers.Changing IPs: Cloudflare Workers, AWS Lambda, Vercel.
What is checkedThe sender IP must be registered.The signature and X-Timestamp. The IP is not checked.
GET requestsX-API-Key only.X-API-Key, X-Signature, X-Timestamp.
POST requestsX-API-Key and X-Signature.X-API-Key, X-Signature, X-Timestamp.
What is signedThe raw JSON body.METHOD|PATH|PARAMS|TIMESTAMP.
How to enableAdd 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.

Formula
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);

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
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
PartContent
METHODUppercase: GET, POST.
PATHNo leading slash and no query string: api/v1/gateway/payment.
PARAMSEvery 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.
TIMESTAMPUnix 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);

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.

401 TIMESTAMP_EXPIRED
{
  "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.

IP Whitelist: HMAC over the body
secret_key  : sk_test_contoh_saja
body        : {"merchant_ref_id":"ORDER-001","amount":50000,"payment_code":"QRIS"}
X-Signature : f8fbd182f2c14ae0b8a231f0687e4070f095fe1881374f09f6a4634cf021fbfa
Enhanced HMAC: HMAC over string_to_sign
secret_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    : 46e51144b78aefcf73342111a465ddd6aede25abdcfbfdbb4c6cf1b792116402

Which authentication errors can occur?#

HTTPCodeMeaning
401MISSING_API_KEYThe X-API-Key header was not sent.
401INVALID_API_KEYThe API key is wrong, has been replaced, or the account is not active.
403IP_WHITELIST_NOT_CONFIGUREDIP Whitelist mode is on but no IP is registered. Add an IP, or turn the whitelist off to use Enhanced HMAC.
403IP_NOT_WHITELISTEDThe sender IP is not registered. Register the IP shown in your_ip.
401MISSING_SIGNATUREA POST request in IP Whitelist mode without an X-Signature header.
401INVALID_SIGNATUREThe body signature does not match. Sign exactly the bytes you send.
401MISSING_HMAC_SIGNATUREEnhanced HMAC mode is on but X-Signature or X-Timestamp was not sent.
401TIMESTAMP_EXPIREDX-Timestamp is more than 300 seconds away from server time. Compare it with data.server_time.
401INVALID_HMAC_SIGNATUREThe 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