PayIns
Create incoming payments and return the customer action required by the selected route.
Create PayIns and PayOuts, route across enabled payment methods, and track every operation through a predictable lifecycle.
201Create incoming payments and return the customer action required by the selected route.
Send funds with operational balance reservation built into the processing flow.
Receive signed, retryable webhook events whenever an operation changes state.
Use the credentials from your Integration page to create a signed PayIn request from your backend.
You receive an API client key and secret plus a separate merchant signing secret. Never expose either secret in browser or mobile application code.
Copy your merchant code and API key from the merchant cabinet. Retrieve both 32-byte signing secrets issued during onboarding from your secret manager.
Serialize JSON once, hash those exact bytes, and use the nested HMAC construction below. Do not reformat the body after signing.
Use a new idempotency key for each logical operation. Reuse that key only when retrying the same request body.
import crypto from "node:crypto";
const apiBase = "https://api.spqr-payments.com";
const apiKey = process.env.SPQR_API_KEY;
const apiClientSecret = Buffer.from(process.env.SPQR_API_CLIENT_SECRET, "hex");
const merchantSecret = Buffer.from(process.env.SPQR_MERCHANT_SECRET, "hex");
const body = JSON.stringify({
merchant: "acme_my",
amount: 12500,
currency: "MYR",
external_id: "order-8402",
country: "MY",
payment_method: "duitnowqr",
return_url: "https://shop.example.com/orders/order-8402",
callback_url: "https://api.shop.example.com/webhooks/spqr",
payer: { customer_uid: "customer-184" }
});
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const canonical = `POST\n/payments\n${bodyHash}\n${timestamp}\n`;
const inner = crypto.createHmac("sha256", merchantSecret).update(canonical).digest();
const signature = crypto.createHmac("sha256", apiClientSecret).update(inner).digest("hex");
const response = await fetch(`${apiBase}/payments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
"X-Timestamp": timestamp,
"X-Signature": signature,
"Idempotency-Key": crypto.randomUUID()
},
body
});
console.log(response.status, await response.json());
{
"payment_id": "0198f0d4-15cb-7c98-ae52-4dd112dc14cc",
"status": "PENDING",
"created_at": "2026-08-17T11:42:16.921Z",
"provider_code": "newmac",
"client_action": {
"type": "redirect",
"url": "https://checkout.provider.example/session/8f0f"
}
}Response fields such as provider_code, route_id, and client_action appear when the selected route supplies them. Treat unknown client_action.data fields as forward-compatible provider instructions.
Every merchant API request is authenticated with a timestamped, nested HMAC-SHA256 signature.
X-Api-KeyIdentifies the API client and scopes it to a franchise. Safe to include as a request header, but not a substitute for either secret.
Signs the canonical request first. It binds the request to the merchant code in the body or query string.
Signs the raw 32-byte inner digest. It binds the request to the calling integration.
The canonical UTF-8 string has four values, each followed by a newline. For GET requests, hash an empty body. The path includes the exact raw query string when present.
<UPPERCASE_METHOD>
<PATH_WITH_OPTIONAL_RAW_QUERY>
<LOWERCASE_HEX_SHA256_OF_BODY>
<X_TIMESTAMP>
signature = hex(HMAC-SHA256(api_client_secret, HMAC-SHA256(merchant_secret, canonical)))The default accepted clock skew is ±5 minutes. Synchronize servers with NTP and generate a fresh timestamp for every retry.
Sign the same raw query order you send. For example, /payments/{id}?merchant=acme_my.
Use a timing-safe comparison when verifying SPQR webhook signatures on your server.
Create a customer payment, present the returned client action when required, and use webhooks or retrieval to observe the final state.
merchantrequiredstringYour SPQR merchant code.amountrequiredint64Positive amount in minor units. MYR 125.00 is 12500.currencyrequiredstringThree-letter ISO 4217 code, such as MYR.external_idrequiredstringYour order ID. It identifies the order in your system, not at the PSP.countryrequiredstringTwo-letter ISO 3166-1 alpha-2 code, such as MY.payment_methodrequiredstringAn enabled SPQR method code for the requested country and currency.return_urlURICustomer return destination. Defaults to the URL in merchant settings.callback_urlURIHTTPS webhook endpoint. Defaults to the URL in merchant settings.payerobjectProvider-specific payer metadata. Ask the integration team which fields your corridor requires.provider_idUUIDLegacy route lock. Omit this field to let SPQR select the route.When client_action.url is present, redirect the customer to it. When client_action.data is present, render or pass through the provider instruction appropriate for your approved payment method.
The full path and raw query are part of the HMAC signature. A pending operation may be refreshed from the routing layer before the response is returned.
Create a disbursement with recipient details. SPQR reserves operational balance before provider processing begins.
The common fields match a PayIn, while recipient replaces payer. For MYR bank transfers, send all values as strings:
bank_code — recipient bank identifieraccount_name — account holder nameaccount_number — account number with leading zeroes preserved{
"merchant": "acme_my",
"amount": 25000,
"currency": "MYR",
"external_id": "withdrawal-184",
"country": "MY",
"payment_method": "bank_transfer",
"callback_url": "https://api.shop.example.com/webhooks/spqr",
"recipient": {
"bank_code": "MBBEMYKL",
"account_name": "Alex Tan",
"account_number": "5144229981"
}
}
The create call can return provider_balance_insufficient with HTTP 422 when funds cannot be reserved. Failed or cancelled processing releases its reservation.
/payouts/{id}?merchant={merchant_code}Return the latest state and reservation ID when available.
ReferenceMethod codes are stable merchant-facing values. SPQR maps them to provider products through routes configured for your account.
| Flow | Method code | Customer experience | Configured range |
|---|---|---|---|
duitnowqr | DuitNow QR | MYR 10–30,000 | |
fpx | FPX online banking | MYR 50–20,000 | |
tng | Touch 'n Go direct | MYR 10–5,000 | |
tngqr | Touch 'n Go QR | MYR 10–30,000 | |
grabpay | GrabPay direct | MYR 10–5,000 | |
grabpayqr | GrabPay QR | MYR 10–30,000 | |
shopee | ShopeePay direct | MYR 10–5,000 | |
gxbankqr | GXBank QR | MYR 10–50,000 | |
boostqr | Boost QR | MYR 10–30,000 | |
ebank | Online banking | MYR 10–30,000 | |
| PayOut | bank_transfer | Bank account transfer | MYR 10–30,000 |
| PayOut | tng_payout | Touch 'n Go payout | MYR 10–30,000 |
SPQR also contains routes for additional corridors and providers. They are not listed as generally available because every route ships disabled and is activated per commercial and operational approval.
PayIns and PayOuts share one merchant-facing state machine. Terminal states do not transition again.
NEW can also move directly to CANCELLED. The status_times object records the first UTC timestamp at which each state family was reached.
Status answers what happened; resolution explains why. Use both in operational dashboards and customer-support tooling.
okSuccessful terminal outcome
no_route_availableNo eligible merchant route
provider_error_retryableProvider fault may need review
provider_error_terminalProvider rejected permanently
limit_violationAmount or turnover limit
attempt_limit_reachedAll route attempts exhausted
provider_balance_insufficientOperational funds unavailable
cancelledOperation was cancelled
A retryable provider error can set moderation_required: true. Keep the operation in an operator-review flow instead of treating it as a safe final decline.
SPQR posts a signed event to the request callback URL, or to the default callback URL in merchant settings, after each status transition.
{
"event_id": "0198f0d9-d219-77e8-9419-c2dbfc54889d",
"event_type": "payment.status_changed",
"merchant": "acme_my",
"operation_type": "payment",
"operation_id": "0198f0d4-15cb-7c98-ae52-4dd112dc14cc",
"external_id": "order-8402",
"status": "SUCCEEDED",
"resolution": "ok",
"moderation_required": false,
"status_times": {
"new_at": "2026-08-17T11:42:16.921Z",
"pending_at": "2026-08-17T11:42:17.244Z",
"succeeded_at": "2026-08-17T11:42:31.808Z"
},
"provider_code": "newmac",
"amount": 12500,
"currency": "MYR"
}
Build the canonical path from your callback URL, including its raw query. Use the raw request body before JSON parsing and compare the received signature in constant time.
function verifySPQRWebhook({ method, originalUrl, rawBody, headers }) {
const timestamp = headers["x-timestamp"];
const received = headers["x-signature"];
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const canonical = `${method.toUpperCase()}\n${originalUrl}\n${bodyHash}\n${timestamp}\n`;
const inner = crypto.createHmac("sha256", merchantSecret).update(canonical).digest();
const expected = crypto.createHmac("sha256", apiClientSecret).update(inner).digest();
const signature = Buffer.from(received, "hex");
return signature.length === expected.length &&
crypto.timingSafeEqual(signature, expected);
}
PayOut events use payout.status_changed, operation_type: "payout", and also include the original recipient object.
All API errors use a consistent envelope. Log request_id and share it with the integration team when troubleshooting.
The key was already used for a different logical request.
{
"error": {
"code": "idempotency_mismatch",
"message": "idempotency key reused with different request body",
"request_id": "7c6bfb2944246c12",
"details": []
}
}validation_errorCorrect the request. Use details for field-level feedback.unauthorizedCheck the API key, merchant scope, timestamp, and credential status.invalid_signatureCompare exact body bytes, path/query, trailing newlines, and secret decoding.merchant_disabledContact SPQR operations; the merchant or requested flow is disabled.not_foundConfirm the merchant code and operation ID are in the authenticated scope.idempotency_mismatchDo not retry with this key. Investigate the conflicting request.conflictRetrieve the operation and reconcile its current state.provider_balance_insufficientDo not blind-retry. Wait for funds or choose an approved alternative route.internal_errorRetry safely with the same idempotency key and identical body.The public contract contains four merchant endpoints. Status mutation endpoints are private to SPQR services.
X-Api-Key · X-Timestamp · X-Signature · Idempotency-Key201 on create; 200 on an identical idempotent replay.merchant, amount, currency, external_id, country, and payment_method are required.payment_id, status, created_at, with route and client-action fields when available.merchant — the owning merchant code.X-Api-Key · X-Timestamp · X-Signature200 with the current status, outcome metadata, amount, and lifecycle timestamps.X-Api-Key · X-Timestamp · X-Signature · Idempotency-Key201 on create; 200 on an identical idempotent replay.recipient object.payout_id, status, created_at, and route fields when available.merchant — the owning merchant code.X-Api-Key · X-Timestamp · X-Signature200 with the current status, recipient, lifecycle timestamps, and reservation ID when present.Download the merchant-only YAML contract for code generation, API clients, or contract tests.