APIhttps://api.spqr-payments.com
API specification
SPQR Payments API

Move money with
one clear contract.

Create PayIns and PayOuts, route across enabled payment methods, and track every operation through a predictable lifecycle.

Nested HMAC-SHA256 Idempotent creates Signed webhooks
create-payin.jsProduction
Payment routedPENDING201

PayIns

Create incoming payments and return the customer action required by the selected route.

PayOuts

Send funds with operational balance reservation built into the processing flow.

Lifecycle events

Receive signed, retryable webhook events whenever an operation changes state.

01
First request

Quickstart

Use the credentials from your Integration page to create a signed PayIn request from your backend.

Keep both signing secrets server-side

You receive an API client key and secret plus a separate merchant signing secret. Never expose either secret in browser or mobile application code.

1

Collect your credentials

Copy your merchant code and API key from the merchant cabinet. Retrieve both 32-byte signing secrets issued during onboarding from your secret manager.

2

Build and sign the exact body

Serialize JSON once, hash those exact bytes, and use the nested HMAC construction below. Do not reformat the body after signing.

3

Send from your backend

Use a new idempotency key for each logical operation. Reuse that key only when retrying the same request body.

Node.js 18+
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());
201CreatedThe request has been accepted and routed.
{
  "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.

02
Request security

Authentication

Every merchant API request is authenticated with a timestamped, nested HMAC-SHA256 signature.

Public

X-Api-Key

Identifies the API client and scopes it to a franchise. Safe to include as a request header, but not a substitute for either secret.

Secret 01

Merchant secret

Signs the canonical request first. It binds the request to the merchant code in the body or query string.

Secret 02

API client secret

Signs the raw 32-byte inner digest. It binds the request to the calling integration.

Canonical request

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.

Plain text
<UPPERCASE_METHOD>
<PATH_WITH_OPTIONAL_RAW_QUERY>
<LOWERCASE_HEX_SHA256_OF_BODY>
<X_TIMESTAMP>
Exact request bytescanonical
HMAC-SHA256
merchant secret
Raw 32 bytesinner digest
HMAC-SHA256
API client secret
Lowercase hexX-Signature
signature = hex(HMAC-SHA256(api_client_secret, HMAC-SHA256(merchant_secret, canonical)))
Clock window

The default accepted clock skew is ±5 minutes. Synchronize servers with NTP and generate a fresh timestamp for every retry.

GET query order

Sign the same raw query order you send. For example, /payments/{id}?merchant=acme_my.

Constant-time compare

Use a timing-safe comparison when verifying SPQR webhook signatures on your server.

03
Incoming funds

PayIns

Create a customer payment, present the returned client action when required, and use webhooks or retrieval to observe the final state.

POST/payments

Create and route a PayIn.

Reference

Request fields

FieldTypeDescription
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.
Handle the client action before polling

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.

Retrieve a PayIn

GET/payments/{id}?merchant={merchant_code}

Return the latest merchant-facing state.

Reference

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.

04
Outgoing funds

PayOuts

Create a disbursement with recipient details. SPQR reserves operational balance before provider processing begins.

POST/payouts

Create, reserve, and route a PayOut.

Reference

PayOut-specific data

The common fields match a PayIn, while recipient replaces payer. For MYR bank transfers, send all values as strings:

  • bank_code — recipient bank identifier
  • account_name — account holder name
  • account_number — account number with leading zeroes preserved
JSON
{
  "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"
  }
}
A PayOut depends on available operational balance

The create call can return provider_balance_insufficient with HTTP 422 when funds cannot be reserved. Failed or cancelled processing releases its reservation.

Retrieve a PayOut

GET/payouts/{id}?merchant={merchant_code}

Return the latest state and reservation ID when available.

Reference
05
Routing catalog

Payment methods

Method codes are stable merchant-facing values. SPQR maps them to provider products through routes configured for your account.

Current MYR route catalog

These methods exist in the production catalog. Only routes explicitly enabled for your merchant are eligible, and your onboarding terms are authoritative for live limits.

MY · MYR
FlowMethod codeCustomer experienceConfigured range
PayInduitnowqrDuitNow QRMYR 10–30,000
PayInfpxFPX online bankingMYR 50–20,000
PayIntngTouch 'n Go directMYR 10–5,000
PayIntngqrTouch 'n Go QRMYR 10–30,000
PayIngrabpayGrabPay directMYR 10–5,000
PayIngrabpayqrGrabPay QRMYR 10–30,000
PayInshopeeShopeePay directMYR 10–5,000
PayIngxbankqrGXBank QRMYR 10–50,000
PayInboostqrBoost QRMYR 10–30,000
PayInebankOnline bankingMYR 10–30,000
PayOutbank_transferBank account transferMYR 10–30,000
PayOuttng_payoutTouch 'n Go payoutMYR 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.

06
Operation lifecycle

Statuses

PayIns and PayOuts share one merchant-facing state machine. Terminal states do not transition again.

NEWCreated locally
PENDINGProvider processing
SUCCEEDEDCompleted
FAILED_PROVIDERProvider rejection
FAILED_INTERNALPlatform failure
CANCELLEDCancelled

NEW can also move directly to CANCELLED. The status_times object records the first UTC timestamp at which each state family was reached.

Resolution gives the reason

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
Pause automation when moderation is required

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.

07
Asynchronous updates

Webhooks

SPQR posts a signed event to the request callback URL, or to the default callback URL in merchant settings, after each status transition.

Delivery contract

  • Respond with any 2xx statusNon-2xx responses and network failures are retried with bounded exponential backoff.
  • Deduplicate by event_idThe event ID remains stable across delivery attempts.
  • Verify before processingWebhooks use the same nested HMAC headers and canonical algorithm as API requests.
  • Return quicklyPersist the event, return 2xx, and continue business work asynchronously.
payment.status_changed
{
  "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"
}

Verify the callback

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.

Express-style Node.js
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.

08
Failure handling

Errors

All API errors use a consistent envelope. Log request_id and share it with the integration team when troubleshooting.

409Idempotency mismatch

The key was already used for a different logical request.

application/json
{
  "error": {
    "code": "idempotency_mismatch",
    "message": "idempotency key reused with different request body",
    "request_id": "7c6bfb2944246c12",
    "details": []
  }
}
HTTPCodeRecommended handling
400validation_errorCorrect the request. Use details for field-level feedback.
401unauthorizedCheck the API key, merchant scope, timestamp, and credential status.
401invalid_signatureCompare exact body bytes, path/query, trailing newlines, and secret decoding.
403merchant_disabledContact SPQR operations; the merchant or requested flow is disabled.
404not_foundConfirm the merchant code and operation ID are in the authenticated scope.
409idempotency_mismatchDo not retry with this key. Investigate the conflicting request.
409conflictRetrieve the operation and reconcile its current state.
422provider_balance_insufficientDo not blind-retry. Wait for funds or choose an approved alternative route.
500internal_errorRetry safely with the same idempotency key and identical body.
09
Endpoint details

API reference

The public contract contains four merchant endpoints. Status mutation endpoints are private to SPQR services.

Required headersX-Api-Key · X-Timestamp · X-Signature · Idempotency-Key
Success201 on create; 200 on an identical idempotent replay.
Request schemamerchant, amount, currency, external_id, country, and payment_method are required.
Response schemapayment_id, status, created_at, with route and client-action fields when available.
Machine-readable contract

Build from the OpenAPI specification

Download the merchant-only YAML contract for code generation, API clients, or contract tests.

Download YAML
Copied to clipboard