Browse docs
Getting started

Authentication

Every request you send is signed with HMAC-SHA256 using your merchant API secret, and every response and notification StablePay sends back is signed the same way.

Request headers

All merchant-initiated requests must carry these four headers.

HeaderExampleDescription
X-MerchantIDM123456Merchant ID assigned by StablePay. Not your merchant name, login, or internal user ID.
X-Timestamp1782295200Unix timestamp in seconds. Not milliseconds, not a formatted date.
X-Nonce8f31a2bc9d4e6f70Random string, 16–32 characters recommended. Must not be empty.
X-Sign7f7d6e3d…121314Lowercase hex HMAC-SHA256 of the string to sign, keyed with your API secret.

Send Content-Type: application/json. The HTTP method is always POST.

String to sign

StablePay verifies signatures over this exact concatenation:

sign_payload
merchantID + "\n" + timestamp + "\n" + nonce + "\n" + rawBody

rawBody is the raw HTTP request body — the exact bytes you put on the wire. Do not prettify, trim, or reorder fields after signing.

example (four lines, separated by \n)
M123456
1782295200
8f31a2bc9d4e6f70
{"payment_method":"CARD","merchant_order_no":"M202606240001","trans_amount":{"currency":"USD","value":"99.99"},...}
Formatting changes the signature

{"order_no":"O1","status":"SUCCESS"} and the same object pretty-printed are business-equivalent but produce different signatures. Build the final body first, sign it, then send it unchanged.

Compute the signature

sign = hex_lower(HMAC_SHA256(secret_key, sign_payload)). The Node sample below is a complete client — it signs the request, sends it, and verifies the response signature; the request examples throughout this reference call its post() helper.

import crypto from "node:crypto";

const BASE_URL = process.env.SP_BASE_URL;       // https://api-test.stablepay.link
const MERCHANT_ID = process.env.SP_MERCHANT_ID; // e.g. M123456
const SECRET_KEY = process.env.SP_SECRET_KEY;   // merchant API secret

function sign(rawBody, timestamp, nonce) {
  const payload = `${MERCHANT_ID}\n${timestamp}\n${nonce}\n${rawBody}`;
  return crypto.createHmac("sha256", SECRET_KEY).update(payload, "utf8").digest("hex");
}

export async function post(path, bodyObject) {
  // Serialize once. Sign these exact bytes, then send these exact bytes.
  const body = JSON.stringify(bodyObject);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString("hex");

  const res = await fetch(BASE_URL + path, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-MerchantID": MERCHANT_ID,
      "X-Timestamp": timestamp,
      "X-Nonce": nonce,
      "X-Sign": sign(body, timestamp, nonce),
    },
    body,
  });

  // Verify the response signature over the raw response body before parsing.
  const raw = await res.text();
  const expected = sign(raw, res.headers.get("x-timestamp"), res.headers.get("x-nonce"));
  if (expected !== (res.headers.get("x-sign") || "").toLowerCase()) {
    throw new Error("Untrusted response: signature mismatch");
  }

  return JSON.parse(raw); // { code, msg, data }
}

Keep your server clock accurate (NTP). X-Timestamp is compared against StablePay's clock, so a drifting clock will eventually cause rejections.

Example request

HTTP
POST /api/v1/payments/query HTTP/1.1
Host: api.stablepay.link
Content-Type: application/json
X-MerchantID: M123456
X-Timestamp: 1782295200
X-Nonce: 8f31a2bc9d4e6f70
X-Sign: 7f7d6e3d2baf9b9bd0e4d8d9a5d2c8d9f7c4e1a2b3c4d5e6f708091011121314

{"merchant_order_no":"M202606240001"}

Verify responses

Responses from StablePay carry the same four headers — X-MerchantID, X-Timestamp, X-Nonce, X-Sign — and are signed over the same shape:

merchantID + "\n" + timestamp + "\n" + nonce + "\n" + rawResponseBody
  • Read the response body as a raw string and verify before you deserialize it.
  • Do not deserialize and re-serialize before verifying.
  • If verification fails, treat the response as untrusted and do not act on it.

Webhook notifications use the identical rule over the notification body.

Troubleshooting

When you receive 401 Unauthorized, 403, or 1003 Signature verification failed, work through the signing chain in this order:

CheckCommon mistake
Merchant IDSending a merchant name, login account, or internal user ID instead of the assigned ID.
SecretUsing the dashboard password, or mixing test and production secrets.
Line breaksMissing separators, or using \r\n instead of \n.
OrderConcatenating in the wrong order — it is always merchantID, timestamp, nonce, body.
BodyTrimming, prettifying, re-ordering keys, or signing a re-serialized object instead of the bytes sent.
Field namescamelCase keys (paymentMethod, merchantOrderNo, goodsName). The API is snake_case only.
TimestampMilliseconds (1782295200123) or an ISO date string instead of Unix seconds.
NonceEmpty nonce header.
Output formatBase64, uppercase hex, MD5, or RSA. Only lowercase-hex HMAC-SHA256 is accepted.
Content-TypeAnything other than application/json.
Log these on every failure

merchant_id, timestamp, nonce, raw_body, sign_payload, received_sign, expected_sign. Comparing sign_payload byte-for-byte against what you sent is the fastest way to tell a header problem from a body-formatting problem from a wrong secret.

Amount-limit errors (2006, 5008) are not signature-related — see Responses & errors.