Standard Payment Protocol
Audience: Third-party payment service providers (PSPs)
Protocol version: STANDARD_V1
Signature algorithm: HMAC_SHA256
Integration mode: REDIRECT (hosted checkout page)
Document date: 2026-07-20 (revised 2026-07-29: amount unit corrected to major currency units in both directions; /return endpoint semantics clarified)
This document is the complete customer-facing integration guide for connecting your payment service to the merchant platform via Standard Payment Protocol v1.
It covers:
- The HTTP API you must expose (
POST /v1/payments/init) - The asynchronous payment notify you must send to the platform
- The shared HMAC-SHA256 signature scheme used in both directions
- Amount / currency rules, security checklist, and FAQ
Before you start
This guide is the technical reference for PSP developers. If you are a platform administrator looking to configure a payment provider in the Admin Console, see Payment Providers for the UI workflow.
Overview
| Role | Responsibility |
|---|---|
| Merchant platform | Creates payment orders, redirects users to your checkout page, receives payment result notifications, credits the user |
| Your PSP | Exposes POST /v1/payments/init, hosts the checkout UI, collects payment, sends a signed async notify back to the platform |
End-to-end flow
User Merchant Platform Your PSP
| | |
|-- select channel ------>| |
| |-- POST /v1/payments/init (signed)->|
| |<-- { paymentId, checkoutUrl } -----|
|<-- redirect checkoutUrl-| |
|---------------------- pay on your page --------------------->|
| |<-- POST .../notify (signed) -------|
| |-- HTTP 200 {"code":0,"message":"ok"}
| | (credit user on SUCCESS) |
Environments & base URLs
| Item | Description |
|---|---|
| Your API base URL | Configured per tenant as apiBaseUrl (no trailing slash), e.g. https://psp.example.com |
| Platform callback base URL | Provided during onboarding / tenant config (callbackBaseUrl), e.g. https://console.example.com |
| Notify URL template | {callbackBaseUrl}/xt-console/api/payment/callback/{providerCode}/{partnerId}/notify |
providerCode and partnerId are assigned during onboarding. Treat them as opaque path segments.
Credentials and onboarding
Each tenant (partner) is configured with:
| Field | Required | Description |
|---|---|---|
merchantId | Yes | Merchant identifier issued by you |
apiBaseUrl | Yes | Base URL of your Standard Payment API |
secretKey | Yes | Shared secret for HMAC-SHA256 (UTF-8) |
defaultCurrency | No | Default ISO-4217 currency, e.g. USD |
paymentLifetimeSeconds | No | Order lifetime in seconds (platform default: 900) |
ipAllowlist | No | If set, only these source IPs may call notify |
Reserved provider codes that cannot be used for Standard v1: stripe, freedompay.
After configuration, the tenant must test → activate the channel before checkout is allowed. (The test step currently validates configuration completeness — merchantId, apiBaseUrl, secretKey — it does not call your API. Verify end-to-end connectivity with a sandbox round-trip, see §9.)
API you must implement
Init payment
Create a payment session and return a hosted checkout URL.
Endpoint
POST {apiBaseUrl}/v1/payments/init
Content-Type: application/json
X-Pay-Timestamp: {unix_seconds}
X-Pay-Sign: {hmac_hex}
Request body
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | string | Yes | Merchant ID from tenant config |
orderId | string | Yes | Platform order ID (opaque). Format example: SP{millis}{8hex} |
amount | string | Yes | Amount in major currency units as a decimal string (see §5). Integral amounts are sent without decimal places (e.g. "10", not "10.00"); non-integral amounts use two decimals (e.g. "10.50"). Always use the received raw string when verifying the signature |
currency | string | Yes | ISO-4217 currency code, uppercase (e.g. USD) |
notifyUrl | string | Yes | Absolute URL for async payment result notification |
returnUrl | string | Yes | Browser return URL after payment (may include transactionNo) |
timestamp | string | Yes | Unix epoch seconds (same value as X-Pay-Timestamp) |
Example:
{
"merchantId": "M0001",
"orderId": "SP1234567890abcdef",
"amount": "10",
"currency": "USD",
"notifyUrl": "https://console.example.com/xt-console/api/payment/callback/acme_pay/42/notify",
"returnUrl": "https://merchant.example.com/billing/success?transactionNo=SP1234567890abcdef",
"timestamp": "1751450400"
}
Note: The signature is sent in the
X-Pay-Signheader, not in the JSON body.
Success response (HTTP 2xx)
{
"code": 0,
"message": "ok",
"data": {
"paymentId": "pay_001",
"checkoutUrl": "https://psp.example.com/checkout/pay_001",
"expireAt": "2026-07-20T08:00:00Z"
}
}
| Field | Required | Description |
|---|---|---|
code | Yes | Must be 0 for success |
data.paymentId | Yes | Your payment ID (stored as external reference) |
data.checkoutUrl | Yes | Absolute URL to redirect the payer |
data.expireAt | Recommended | Session / payment expiry (ISO-8601 or equivalent string) |
Failure response
Return a non-zero code and/or a non-2xx HTTP status. The platform will abort checkout.
Idempotency (recommended)
If the same orderId is initialized again, return the existing paymentId / checkoutUrl instead of creating a duplicate order.
Async notification
After the payer completes (or fails / abandons) payment, call the notifyUrl from the init request.
Endpoint
POST {notifyUrl}
Content-Type: application/json
X-Pay-Timestamp: {unix_seconds}
X-Pay-Sign: {hmac_hex}
GET with query parameters is also accepted. Prefer POST + JSON body.
There is also an alternative GET-form notification endpoint:
GET {callbackBaseUrl}/xt-console/api/payment/callback/{providerCode}/{partnerId}/return
It uses the same verification & processing pipeline as /notify and responds with a JSON ack (not a redirect). Do not use it as a browser redirect target — the payer would see raw JSON. For the browser return after payment, redirect the payer to the returnUrl provided in the init request (e.g. via a "Back to merchant" button or auto-redirect on your checkout result page). Always use server-to-server /notify as the source of truth.
Notify fields
| Field | Required | Description |
|---|---|---|
orderId | Yes | Must equal the init orderId |
status | Yes | SUCCESS / FAILED / CLOSED (case-insensitive) |
timestamp | Yes | Unix epoch seconds |
sign | Yes* | Signature (optional if X-Pay-Sign header is present) |
amount | Required for SUCCESS | Major currency units, same as init. Must be numerically equal to the platform order amount (see §5) |
currency | Required for SUCCESS | Must match the platform order currency (case-insensitive) |
paymentId | Recommended | Your payment ID |
* Provide signature via X-Pay-Sign header and/or body/query field sign.
Example body (SUCCESS):
{
"orderId": "SP1234567890abcdef",
"status": "SUCCESS",
"amount": "10.00",
"currency": "USD",
"paymentId": "pay_001",
"timestamp": "1751450400",
"sign": "<hmac_hex>"
}
Status semantics
status | Platform behavior |
|---|---|
SUCCESS | Mark order paid and credit the user (idempotent if already success) |
FAILED | Mark order failed |
CLOSED | Mark order closed / cancelled |
| other | Rejected with HTTP 400 |
Ack responses
| HTTP | Body | Meaning |
|---|---|---|
| 200 | {"code":0,"message":"ok"} | Accepted — stop retrying |
| 400 | {"code":400,"message":"orderId is required"} | Bad request |
| 400 | {"code":400,"message":"amount mismatch"} | SUCCESS amount/currency mismatch |
| 400 | {"code":400,"message":"invalid order status"} | Order not in a payable state |
| 400 | {"code":400,"message":"unsupported status"} | Unknown status |
| 401 | {"code":401,"message":"invalid signature"} | Signature verification failed |
| 401 | {"code":401,"message":"timestamp expired"} | Timestamp outside allowed skew |
| 403 | {"code":403,"message":"ip not allowed"} | Source IP not in allowlist |
| 404 | {"code":404,"message":"order not found"} | Unknown orderId |
Retry and idempotency
- Retry notify until you receive HTTP 200 with
code:0. - Use exponential backoff (e.g. 1s, 5s, 30s, 2m, …).
- Duplicate
SUCCESSnotifies for an already-successful order also return 200 /ok(safe to retry). - Timestamp skew window: ±300 seconds by default.
Amount and currency rules
| Direction | Unit | Example (USD 10.00) |
|---|---|---|
| Init request (platform → PSP) | Major currency decimal string | "10" (integral) or "10.50" |
| SUCCESS notify (PSP → platform) | Major currency decimal string (same unit as init) | "10", "10.0" or "10.00" |
| Currency | ISO-4217 | "USD" |
Rules:
- Always use a string amount (avoid floating-point JSON numbers).
- Currency must be a 3-letter ISO code (uppercase recommended).
- Init sends the amount in major currency units. Integral amounts have no decimal places (
"10"); non-integral amounts use two decimals ("10.50"). - On
SUCCESSnotify,amountis also in major currency units and must be numerically equal to the platform order amount (tolerance < 0.001). Formatting variants such as"10","10.0"and"10.00"are all accepted. Example: init sent"10"→ notify with"10"or"10.00". Never send minor units (cents) — e.g."1000"for USD 10 will be rejected withamount mismatch. - On
SUCCESSnotify,currencymust match the order currency (case-insensitive). - For
FAILED/CLOSED, amount/currency are not validated the same way, but including them is still recommended for reconciliation.
Signature algorithm
Both directions use the same algorithm.
Steps
- Collect all business parameters as a
Map<String, String>. - Remove the key
sign(case-insensitive) if present. - Drop entries whose value is blank / null.
- Sort remaining entries by key ascending (ASCII /
TreeMaporder). - Join as
key=value&key=value&...(no URL-encoding of values for the sign payload). - Build the payload string (four lines,
\nseparators):
{HTTP_METHOD_UPPERCASE}
{path}
{timestamp}
{sorted_params}
- Compute
HMAC-SHA256(payload, secretKey)with UTF-8 encoding. - Output lowercase hex (
%02xper byte). - Verification is case-insensitive.
Path rules
| Call | Method | path used in signature |
|---|---|---|
| Init payment (platform → you) | POST | /v1/payments/init |
| Notify (you → platform) | POST or GET | Full request URI path, e.g. /xt-console/api/payment/callback/acme_pay/42/notify |
For notify, sign the path only (no scheme/host/query). Example:
/xt-console/api/payment/callback/acme_pay/42/notify.
Headers
| Header | Usage |
|---|---|
X-Pay-Timestamp | Unix seconds. For notify, signature and skew validation are based on the body/query timestamp field; send the same value in this header for transport redundancy |
X-Pay-Sign | Hex HMAC signature |
Worked example: init
Secret: your-shared-secret
Timestamp: 1751450400
Sorted params:
amount=10¤cy=USD&merchantId=M0001¬ifyUrl=https://console.example.com/xt-console/api/payment/callback/acme_pay/42/notify&orderId=SP1234567890abcdef&returnUrl=https://merchant.example.com/billing/success?transactionNo=SP1234567890abcdef×tamp=1751450400
Payload:
POST
/v1/payments/init
1751450400
amount=10¤cy=USD&merchantId=M0001¬ifyUrl=https://console.example.com/xt-console/api/payment/callback/acme_pay/42/notify&orderId=SP1234567890abcdef&returnUrl=https://merchant.example.com/billing/success?transactionNo=SP1234567890abcdef×tamp=1751450400
Signature (HMAC-SHA256 hex):
9ad342530a3834774168e93d0a46c0fe7de6e3124b99006b662823db9f8ac347
Pseudo-code (Java):
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(payload.getBytes(UTF_8));
// format each byte as lowercase hex → X-Pay-Sign
Pseudo-code (Python):
import hmac, hashlib
sign = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
Worked example: SUCCESS notify
Method: POST
Path: /xt-console/api/payment/callback/acme_pay/42/notify
Secret: your-shared-secret
Timestamp: 1751450400
Sorted params (amount in major currency units, same as init):
amount=10.00¤cy=USD&orderId=SP1234567890abcdef&paymentId=pay_001&status=SUCCESS×tamp=1751450400
Payload:
POST
/xt-console/api/payment/callback/acme_pay/42/notify
1751450400
amount=10.00¤cy=USD&orderId=SP1234567890abcdef&paymentId=pay_001&status=SUCCESS×tamp=1751450400
Signature (HMAC-SHA256 hex):
3944d5892c8781e35d2bd2bc72a9b065d5c26219e9eb38e504de7d3e675ecddd
Security checklist
- Keep
secretKeyonly on the server; never expose it to browsers or mobile apps. - Reject init / notify requests with invalid signatures.
- Reject timestamps outside the allowed skew (±300s).
- Prefer HTTPS for both your API and the platform notify URL.
- Optionally configure an IP allowlist for notify sources.
- Treat
orderIdas opaque; do not parse platform-specific formats. - On SUCCESS, send amount in major currency units (numerically equal to the platform order amount) and matching currency.
Related platform endpoints
These endpoints are provided by the merchant platform (not implemented by the PSP). Listed here so you understand how traffic reaches you.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /xt-console/api/standard-payment/channels | Login | List active payment channels for the partner |
POST | /xt-console/api/standard-payment/checkout | Login | Create checkout → calls your /v1/payments/init |
POST/GET | /xt-console/api/payment/callback/{providerCode}/{partnerId}/notify | Public | Receives your payment notify |
GET | /xt-console/api/payment/callback/{providerCode}/{partnerId}/return | Public | GET-form notify alternative (same processing as notify, JSON response — not a browser redirect) |
Admin configuration (login required): /xt-console/api/admin/payment-providers/**
Integration checklist for PSPs
- Implement
POST /v1/payments/initwith signature verification. - Host a checkout page at the returned
checkoutUrl. - After payment, POST a signed notify to
notifyUrl. - On
SUCCESS, sendamountin major currency units (numerically equal to the platform order) and matchingcurrency. - Retry until HTTP 200 +
{"code":0,"message":"ok"}. - Provide sandbox credentials (
merchantId,apiBaseUrl,secretKey) for tenant onboarding. - Confirm notify source IPs if allowlisting is required.
- Validate a full round-trip in the sandbox (init → pay → SUCCESS notify → credit).
FAQ
Q: Can I put the signature in the JSON body?
A: For init, the platform sends X-Pay-Sign in the header (body has no sign). For notify, you may send either the header or a sign field (or both).
Q: What if my notify arrives twice?
A: Safe. A second SUCCESS for an already-successful order returns 200 / ok.
Q: Which unit does amount use?
A: Major currency units in both directions (init and notify), as a decimal string. The notify amount is compared numerically against the order amount, so "10", "10.0" and "10.00" are equivalent. Do not convert to cents — sending "1000" for a USD 10 order will be rejected with amount mismatch.
Q: Do I need to implement refund / query APIs? A: Not required by Standard Payment v1 today. Capabilities may be extended later.
Q: Which character encoding? A: UTF-8 for both the payload string and the secret key bytes.
Contact and change control
- Protocol adapter type:
STANDARD_V1 - Signature algorithm label:
HMAC_SHA256 - Breaking changes to path layout, required fields, or the signature payload format will bump the protocol version.
For onboarding, sandbox credentials, or production cutover, contact your integration owner.