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.
| Header | Example | Description |
|---|---|---|
X-MerchantID | M123456 | Merchant ID assigned by StablePay. Not your merchant name, login, or internal user ID. |
X-Timestamp | 1782295200 | Unix timestamp in seconds. Not milliseconds, not a formatted date. |
X-Nonce | 8f31a2bc9d4e6f70 | Random string, 16–32 characters recommended. Must not be empty. |
X-Sign | 7f7d6e3d…121314 | Lowercase 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:
merchantID + "\n" + timestamp + "\n" + nonce + "\n" + rawBodyrawBody is the raw HTTP request body — the exact bytes you put on the wire. Do not prettify, trim, or reorder fields after signing.
M123456
1782295200
8f31a2bc9d4e6f70
{"payment_method":"CARD","merchant_order_no":"M202606240001","trans_amount":{"currency":"USD","value":"99.99"},...}{"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
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:
| Check | Common mistake |
|---|---|
| Merchant ID | Sending a merchant name, login account, or internal user ID instead of the assigned ID. |
| Secret | Using the dashboard password, or mixing test and production secrets. |
| Line breaks | Missing separators, or using \r\n instead of \n. |
| Order | Concatenating in the wrong order — it is always merchantID, timestamp, nonce, body. |
| Body | Trimming, prettifying, re-ordering keys, or signing a re-serialized object instead of the bytes sent. |
| Field names | camelCase keys (paymentMethod, merchantOrderNo, goodsName). The API is snake_case only. |
| Timestamp | Milliseconds (1782295200123) or an ISO date string instead of Unix seconds. |
| Nonce | Empty nonce header. |
| Output format | Base64, uppercase hex, MD5, or RSA. Only lowercase-hex HMAC-SHA256 is accepted. |
| Content-Type | Anything other than application/json. |
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.