商户 API 对接手册
更新:2026-09-11
01 接入说明
开通时平台提供:环境地址、app_id、api_key、api_secret,以及本商户已开通的支付方式清单(含各方式支持的币种与收付方向)。商户需提供:收款与出款的通知地址、服务器出口 IP(加入白名单后才能调用接口)。
| 方法 | 路径 | 用途 |
|---|---|---|
POST | /api/v1/payin/create | 代收下单 |
GET | /api/v1/payin/{payin_order_no} | 代收查单 |
POST | /api/v1/payout/create | 代付下单 |
GET | /api/v1/payout/{payout_order_no} | 代付查单 |
GET | /api/v1/balance | 余额查询 |
1.1 通用约定
- 请求与响应均为 JSON,UTF-8 编码;所有接口按第 2 节签名。
- 统一响应:
{"code": 0, "message": "成功", "data": {…}, "trace_id": "…", "timestamp": 1756450000}。code=0表示请求已受理,不表示支付成功,订单结果看data.status;失败时无data。排障时提供trace_id。 data中的字段一律为字符串,无值为空串"",不会出现null。- 金额为主单位十进制字符串,如
"100.00";小数位不得超过币种精度,不补零也可,平台不做四舍五入。 - 时间为 RFC3339 格式的 UTC 时间,如
2026-09-11T08:00:00Z。 - 订单状态
status为字符串枚举,取值见第 9.1 节;通知中的status只有SUCCESS与FAIL。 merchant_order_no是商户订单号,也是幂等键:同一订单号重发相同内容返回原订单;内容不同返回10003,不会产生第二笔。收不到响应时用原订单号原样重发,不要换号。
02 签名
2.1 请求头
| Header | 说明 |
|---|---|
X-App-Id | 平台分配的应用 ID |
X-Api-Key | 平台分配的 API Key |
X-Timestamp | Unix 秒级时间戳,与服务器时间差不得超过 300 秒 |
X-Nonce | 随机字符串,单次使用;每次发送(含重试)都要重新生成 |
X-Sign | 按 2.2 计算的签名,小写十六进制 |
2.2 签名算法
HMAC-SHA256,密钥为 api_secret,结果取小写 hex。待签名串固定 8 段,以换行符 \n 连接;某段为空时留空,分隔符不可省略:
METHOD大写。PATH为实际请求路径,含路径参数的真实值(如/api/v1/payin/PO20260829000123),不是路由模板。QUERY为规范化查询串:参数按键升序、同键多值按值升序,键和值分别 URL 编码,空格编码为+,! ' ( ) *必须转义,再以k=v用&连接;无查询参数时为空串。实际请求 URL 使用同一规范化串。- 最后一段是原始请求体字节的 SHA-256 小写 hex;无 body 的请求对空字节串取哈希,即
e3b0c442…7852b855。对实际发送的字节计算,不要反序列化后重新序列化。
2.3 签名示例(Python)
其他语言按同样规则实现即可。使用前替换示例域名和三项凭据;api_secret 只保存在服务端。
import hashlib, hmac, time, uuid, requests
from urllib.parse import urlencode
APP_ID = "your_app_id"
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
BASE = "https://api.example.com"
def canonical_query(params: dict) -> str:
if not params:
return ""
pairs = []
for k in sorted(params):
vs = params[k] if isinstance(params[k], list) else [params[k]]
for v in sorted(map(str, vs)):
pairs.append((str(k), v))
return urlencode(pairs)
def sign(method: str, path: str, query: dict, body: bytes, ts: str, nonce: str) -> str:
body_sha = hashlib.sha256(body).hexdigest()
source = "\n".join([
method.upper(), path, canonical_query(query),
APP_ID, API_KEY, ts, nonce, body_sha,
])
return hmac.new(API_SECRET.encode(), source.encode(), hashlib.sha256).hexdigest()
def request(method: str, path: str, query: dict | None = None, body: bytes = b""):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
headers = {
"X-App-Id": APP_ID,
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign(method, path, query or {}, body, ts, nonce),
"Content-Type": "application/json",
}
query_string = canonical_query(query or {})
url = BASE + path + (("?" + query_string) if query_string else "")
return requests.request(method, url, headers=headers, data=body, timeout=10, allow_redirects=False)
2.4 签名测试向量
接入时先用下面三组向量自检,逐段比对签名原文,再比最终签名。三组凭据固定为 app_id=app_demo、api_key=key_demo、api_secret=secret_demo、X-Timestamp=1789000000、X-Nonce=nonce_demo。签名原文中的换行是真实换行符。
- QUERY
(空)
- RAW_BODY
(空)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 签名原文
GET /api/v1/balance app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 预期 X-Sign
21b0a67b99490232913d9bf2e1206593ab79285441465d925c26f3ff50c62912
- QUERY
(空)
- RAW_BODY
{"merchant_order_no":"PM1","amount":"100.00","currency":"PKR","pay_method":"JAZZCASH"}
- SHA256_HEX(RAW_BODY)
0bf9b9e1505c66dbab60de357ffaff19a19e282eafc4a010f08135d42a0285de
- 签名原文
POST /api/v1/payin/create app_demo key_demo 1789000000 nonce_demo 0bf9b9e1505c66dbab60de357ffaff19a19e282eafc4a010f08135d42a0285de
- 预期 X-Sign
c769c86cb9289b60d5f185a55e472ea36e843451c7580d3ff060e810b1eca980
- QUERY
probe=A&probe=a+b&x=1
- RAW_BODY
(空)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 签名原文
GET /api/v1/balance probe=A&probe=a+b&x=1 app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 预期 X-Sign
0dce9b3f6782a672447c55becb932a90be345f3309c50e39d7c8d32346d8fea2
2.5 排查
20001:A 组不过是段序或分隔符错;B 组不过是 body 哈希没按实际字节算;C 组不过是查询串规范化不一致,JavaScript 的encodeURIComponent默认不转义! ' ( ) *。三组都过仍失败,检查api_secret是否用错环境、PATH是否写成了路由模板。- 偶发
20001或时间戳报错:服务器时间漂移超过 300 秒,做 NTP 同步;X-Nonce重复也会被拒。 20003:出口 IP 不在白名单内,这一层在验签之前。经 NAT 或多可用区部署时出口 IP 可能不止一个,全部提供给技术支持。
03 代收下单 POST /api/v1/payin/create
3.1 请求参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
merchant_order_no | string(≤64) | 是 | 商户订单号,商户内唯一,同时是幂等键 |
amount | string(≤32) | 是 | 金额,主单位字符串,如 "100.00" |
currency | string | 是 | 币种代码,如 PKR |
pay_method | string(2–32) | 是 | 支付方式,如 JAZZCASH。取自开通时提供的清单 |
product_name | string(≤128) | 否 | 商品名 |
notify_url | string(≤512) | 否 | 本单通知地址;为空则用商户默认代收通知地址 |
return_url | string(≤512) | 否 | 付款后页面跳转地址;页面跳转不能作为支付成功凭据 |
payer_name | string(≤128) | 否 | 付款人姓名 |
payer_phone | string(≤32) | 否 | 付款人手机号,巴基斯坦为 03 开头 11 位;部分支付方式必填 |
payer_id_no | string(≤64) | 否 | 付款人证件号,巴基斯坦为 13 位 CNIC;直接扣款方式必填 |
payer_email | string(≤128) | 否 | 付款人邮箱 |
3.2 响应参数
| 字段 | 说明 |
|---|---|
payin_order_no | 平台订单号,查单用 |
merchant_order_no | 商户订单号回显 |
amount / currency | 金额与币种回显,与落库订单一致 |
pay_method | 支付方式,如 JAZZCASH、EASYPAISA |
status | 订单状态,见第 9.1 节 |
cashier_url | 平台收银台地址,可直接引导付款人打开 |
pay_url | 上游支付链接;仅 PENDING 时有值 |
qr_code | 二维码内容,用于生成付款二维码;仅 PENDING 时有值 |
channel_order_no | 支付参考编号,可能为空 |
fail_code / fail_reason | 失败码与失败说明,仅 FAIL 时有值;取值见第 9.2 节 |
{
"code": 0,
"message": "成功",
"data": {
"payin_order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"amount": "100.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"pay_method": "JAZZCASH",
"status": "PENDING",
"cashier_url": "https://cashier.example.com/orderPage/PO202609110001?t=…",
"pay_url": "https://pay.example.com/PO202609110001",
"qr_code": "",
"channel_order_no": "",
"fail_code": "",
"fail_reason": ""
},
"trace_id": "…",
"timestamp": 1789000000
}
PENDING:用 pay_url 或 qr_code 引导付款,或打开 cashier_url。FAIL:看 fail_code,需要再收款时用新的 merchant_order_no 下单。INIT / PROCESSING:上游尚未返回,查单获取后续状态或引导付款人打开 cashier_url,不要当作失败。
04 代收查单 GET /api/v1/payin/{payin_order_no}
路径参数为平台订单号。查单结果是订单状态的权威来源;通知可能重复、延迟或耗尽重试,收到通知后仍以查单为准。
| 字段 | 说明 |
|---|---|
payin_order_no / merchant_order_no | 平台订单号 / 商户订单号 |
status | 订单状态,见第 9.1 节 |
amount / currency | 金额与币种 |
pay_method | 本单的支付方式 |
channel_order_no | 支付参考编号,可能为空 |
pay_url / qr_code | 仅 PENDING 时有值 |
notify_url / return_url | 下单时传入的地址回显 |
expire_time | 订单过期时间 |
success_time | 支付成功时间,未成功为空 |
fail_code / fail_reason | 仅 FAIL 时有值 |
created_at | 创建时间 |
{
"payin_order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"status": "SUCCESS",
"amount": "100.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"pay_method": "JAZZCASH",
"channel_order_no": "JC8823717",
"pay_url": "",
"qr_code": "",
"notify_url": "https://merchant.example.com/notify",
"return_url": "",
"expire_time": "2026-09-11T08:30:00Z",
"success_time": "2026-09-11T08:05:12Z",
"fail_code": "",
"fail_reason": "",
"created_at": "2026-09-11T08:00:00Z"
}
05 代付下单 POST /api/v1/payout/create
5.1 请求参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
merchant_order_no | string(≤64) | 是 | 商户订单号,商户内唯一,同时是幂等键 |
amount | string(≤32) | 是 | 金额,主单位字符串 |
currency | string | 是 | 币种代码 |
pay_method | string(2–32) | 是 | 支付方式。取自开通时提供的清单 |
receiver_name | string(≤128) | 是 | 收款人姓名 |
receiver_account | string(≤128) | 是 | 收款账号:钱包代付填钱包账号(巴基斯坦为 03 开头 11 位手机号);银行代付填银行账号或 IBAN(巴基斯坦为 PK 开头 24 位) |
receiver_bank_code | string(≤64) | 否 | 收款银行代码,银行代付必填;按开通时提供的支持银行清单填写 |
receiver_phone | string(≤32) | 否 | 收款人手机号,巴基斯坦为 03 开头 11 位;部分银行代付必填 |
receiver_id_no | string(≤64) | 否 | 收款人证件号,巴基斯坦为 13 位 CNIC;钱包代付必填 |
receiver_email | string(≤128) | 否 | 收款人邮箱;部分支付方式必填 |
缺少所选支付方式要求的收款人字段时返回 10001,message 会指出缺哪一项。
5.2 响应参数
字段与代收下单同构:payout_order_no、merchant_order_no、amount、currency、pay_method、status、fail_code、fail_reason。受理成功时 status 为 INIT,出款结果通过通知和查单获取。
{
"payout_order_no": "PT202609110001",
"merchant_order_no": "W202609110001",
"amount": "500.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"status": "INIT",
"fail_code": "",
"fail_reason": ""
}
下单成功后,订单金额加手续费从可用余额转入冻结余额;出款失败后解冻回可用余额,出款成功后从冻结余额扣除。可用余额不足时无法下单。
06 代付查单 GET /api/v1/payout/{payout_order_no}
| 字段 | 说明 |
|---|---|
payout_order_no / merchant_order_no | 平台订单号 / 商户订单号 |
status | 订单状态,见第 9.1 节 |
amount / currency | 金额与币种 |
pay_method | 本单的支付方式 |
receiver_name / receiver_account / receiver_bank_code | 收款人信息回显,账号脱敏 |
success_time | 出款成功时间,未成功为空 |
fail_code / fail_reason | 仅 FAIL 时有值 |
created_at | 创建时间 |
{
"payout_order_no": "PT202609110001",
"merchant_order_no": "W202609110001",
"status": "SUCCESS",
"amount": "500.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"receiver_name": "Ali Khan",
"receiver_account": "0300****1234",
"receiver_bank_code": "",
"success_time": "2026-09-11T09:12:40Z",
"fail_code": "",
"fail_reason": "",
"created_at": "2026-09-11T09:10:00Z"
}
PENDING_VERIFY 表示出款结果尚未确认,资金仍冻结。此时用新订单号重新下单可能造成重复打款:保留原订单,等待通知或继续查单,长时间未更新时携带平台订单号联系技术支持。
07 余额查询 GET /api/v1/balance
无请求参数,返回当前商户各币种余额。总余额 = 可用余额 + 冻结余额。
{
"as_of": "2026-09-11T08:00:00Z",
"balances": [
{
"currency": "PKR",
"available_balance": "12345.00",
"frozen_balance": "100.00",
"total_balance": "12445.00"
}
]
}
08 异步通知
订单进入 SUCCESS 或 FAIL 时,平台向商户通知地址发送 POST 请求。代收优先使用下单时的 notify_url,为空则用商户默认代收通知地址;代付使用商户默认代付通知地址。地址未配置则不通知,请依赖查单。
8.1 通知报文
{
"biz_type": "PAYIN",
"order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"amount": "100.00",
"currency": "PKR",
"status": "SUCCESS",
"fail_code": "",
"fail_reason": "",
"finished_at": "2026-09-11T08:05:12Z"
}
| 字段 | 说明 |
|---|---|
biz_type | PAYIN 代收,PAYOUT 代付 |
order_no | 平台订单号(payin_order_no 或 payout_order_no) |
merchant_order_no | 商户订单号 |
amount / currency | 金额与币种,请与商户订单核对 |
status | SUCCESS 或 FAIL |
fail_code / fail_reason | 失败时有值,见第 9.2 节 |
finished_at | 订单终态时间 |
8.2 验签
通知携带与商户请求相同的 5 个签名 Header,按第 2 节规则对通知地址的实际路径、查询参数和原始请求体签名。必须验签通过后再处理,可复用自己的签名代码:
import hmac, json, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/notify")
def on_notify():
body = request.get_data() # 原始字节,勿先解析再序列化
ts = request.headers.get("X-Timestamp", "")
nonce = request.headers.get("X-Nonce", "")
got = request.headers.get("X-Sign", "")
query = request.args.to_dict(flat=False) # 保留同名参数的全部值
want = sign("POST", request.path, query, body, ts, nonce)
if not hmac.compare_digest(got, want):
return "invalid sign", 401
if abs(time.time() - int(ts or 0)) > 300:
return "expired", 401
handle_notification(json.loads(body)) # 商户实现:核对订单、金额、币种后更新本地订单
return "success", 200
8.3 应答与重试
- 验签并处理完成(或可靠保存待处理)后,5 秒内返回 HTTP 200,响应体为纯文本
success。其他响应或超时视为失败;通知地址不得返回重定向。 - 失败后最多重试 3 次,间隔约 15 秒、1 分钟、5 分钟;超过 30 分钟停止。
- 通知可能重复或乱序。按
biz_type + order_no定位订单,同一结果重复到达不得重复处理;收到与本地不同的结果时先查单核对再更新。 - 未收到通知不表示订单失败,以查单为准;长时间无通知时主动查单,逐步拉长间隔。
09 状态与错误码
9.1 订单状态 status
| 取值 | 适用 | 含义 | 商户处理 |
|---|---|---|---|
INIT | 代收 / 代付 | 订单已创建 | 等待通知或查单 |
PENDING | 代收 | 等待付款 | 用支付链接或二维码引导付款 |
PROCESSING | 代收 / 代付 | 上游处理中 | 等待通知或查单,不要重复下单 |
SUCCESS | 代收 / 代付 | 成功(终态) | 核对订单号、金额、币种后更新商户订单 |
FAIL | 代收 / 代付 | 失败(终态) | 按 fail_code 决定是否用新订单号重新下单 |
CLOSED | 代收 / 代付 | 已关闭或过期(终态) | 停止使用原支付链接;需要时用新订单号下单 |
PENDING_VERIFY | 代收 / 代付 | 结果待确认 | 既非成功也非失败,继续查单;不要重新下单,代付侧会造成重复打款 |
9.2 失败码 fail_code
订单为 FAIL 时有值。fail_reason 仅供展示,逻辑判断以 fail_code 为准;取值只增不改,遇到未列出的值按 UNSPECIFIED 处理。
| fail_code | 含义 | 能否重新下单 |
|---|---|---|
CHANNEL_REJECTED | 渠道拒绝交易 | 可以,用新的 merchant_order_no |
PAYMENT_TIMEOUT | 支付窗口内未完成付款 | 可以,用新的 merchant_order_no |
PENDING_MANUAL_REVIEW | 结果不明,平台已挂起核查 | 不要重试,上游可能已扣款或已出账;等待后续通知或查单 |
CHANNEL_UNAVAILABLE | 暂无可用支付服务 | 重试通常无用,联系技术支持 |
ORDER_CLOSED | 订单已关闭或取消 | 需要时用新订单号下单 |
UNSPECIFIED | 未归类的失败 | 先查单确认原订单结果再决定 |
9.3 响应码 code
code 描述本次接口调用的结果,与订单结果无关。请按 code 分支,不要解析 message 文案。
| code | HTTP | 含义 | 商户处理 |
|---|---|---|---|
| 10000 | 500 | 系统错误 | 用原订单号原样重发或查单;持续出现请提供 trace_id 联系平台 |
| 10001 | 400 | 参数错误 | 按 message 修正参数后重发 |
| 10002 | 404 | 资源不存在 | 确认平台订单号及所用凭据后重查 |
| 10003 | 409 | 订单号冲突 | 该 merchant_order_no 已用于内容不同的订单;用查单确认原订单,或换新订单号下单 |
| 10004 | 503 | 服务暂不可用 | 退避后用原订单号原样重发 |
| 10005 | 400 | 币种小数位不受支持 | amount 小数位超过币种精度,平台不做四舍五入 |
| 10006 | 400 | 支付服务未开通 | 该 pay_method 未对本商户开通,联系技术支持;重发不会改变结果 |
| 10007 | 400 | 支付服务暂时无法受理 | 通常是金额超出该支付方式可受理范围;稍后原样重发,或改用其他已开通的支付方式 |
| 20001 | 401 | 签名验证失败 | 按第 2.4 节测试向量自检 |
| 20003 | 403 | 访问被拒绝 | 出口 IP 不在白名单内 |
| 20004 | 403 | 商户被冻结或不可用 | 联系平台 |
| 20005 | 403 | 商户被禁用 | 联系平台 |
| 20006 | 429 | 触发限流 | 退避后重试 |
10 附录:支付方式与支持银行
代收当前支持巴基斯坦(PKR)的 JazzCash、Easypaisa 与二维码扫码;代付支持钱包与银行转账。本商户实际可用的支付方式、币种、下单编码与支持银行清单以开通时提供的为准,不要自行推测银行代码。
pay_method只需传支付方式本身:收付方向由接口决定(/payin/create与/payout/create),币种由currency指定,平台据此唯一确定一条支付通道。银行名称或银行代码不能替代它。建单与查单响应里回显同一个pay_method。- 银行代付:
receiver_bank_code按支持银行清单原样填写,receiver_account填银行账号或 IBAN。 - 钱包代付:
receiver_account填钱包手机号,通常还需receiver_id_no。 - 上线前用测试凭据各完成一次代收和代付,并验证:用原订单号重发不产生第二笔;篡改通知报文后验签失败;重复通知不重复处理。验收通过后由平台单独开通生产凭据,测试凭据不得复用。
Merchant API Handbook
Updated 2026-09-11
01 Getting started
At onboarding the platform issues the API host, app_id, api_key, api_secret, and the list of payment methods enabled for you (with the currencies and directions each supports). You provide your payin and payout notification URLs and your server egress IPs; requests are accepted only from allowlisted IPs.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/payin/create | Create payin |
GET | /api/v1/payin/{payin_order_no} | Query payin |
POST | /api/v1/payout/create | Create payout |
GET | /api/v1/payout/{payout_order_no} | Query payout |
GET | /api/v1/balance | Query balances |
1.1 Conventions
- Requests and responses are UTF-8 JSON; every endpoint is signed as described in section 2.
- Envelope:
{"code": 0, "message": "…", "data": {…}, "trace_id": "…", "timestamp": 1756450000}.code=0means the request was accepted, not that the payment succeeded; readdata.statusfor the order outcome. On failuredatais absent. Quotetrace_idwhen contacting support. - Every field in
datais a string. Absent values are"";nullnever appears. - Amounts are decimal strings in major units, e.g.
"100.00"; at most the currency's number of decimals, trailing zeros optional, no rounding. - Times are RFC3339 in UTC, e.g.
2026-09-11T08:00:00Z. statusis a string enum, see section 9.1; notifications carry onlySUCCESSorFAIL.merchant_order_nois your order number and the idempotency key: resending the same order number with the same body returns the original order; a different body returns10003and never creates a second order. If you get no response, resend the same order number unchanged; do not mint a new one.
02 Signing
2.1 Request headers
| Header | Description |
|---|---|
X-App-Id | Application ID issued by the platform |
X-Api-Key | API key issued by the platform |
X-Timestamp | Unix seconds; must be within 300 seconds of server time |
X-Nonce | Random single-use string; generate a new one for every send, including retries |
X-Sign | Signature from 2.2, lowercase hex |
2.2 Algorithm
HMAC-SHA256 keyed with api_secret, lowercase hex. The signing string has exactly 8 segments joined by \n; an empty segment stays empty but its separator is never dropped:
METHODin upper case.PATHis the actual request path with real path parameters (e.g./api/v1/payin/PO20260829000123), not the route template.QUERYis the canonical query string: keys sorted ascending, multiple values per key sorted ascending, keys and values URL-encoded with space as+and! ' ( ) *escaped, joined ask=vwith&; empty when there are no parameters. Send the same canonical string in the request URL.- The last segment is the SHA-256 (lowercase hex) of the raw request body bytes; requests without a body hash the empty byte string, i.e.
e3b0c442…7852b855. Hash the bytes you actually send; never re-serialise.
2.3 Signing example (Python)
Other languages follow the same rules. Replace the host and the three credentials; keep api_secret server-side only.
import hashlib, hmac, time, uuid, requests
from urllib.parse import urlencode
APP_ID = "your_app_id"
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
BASE = "https://api.example.com"
def canonical_query(params: dict) -> str:
if not params:
return ""
pairs = []
for k in sorted(params):
vs = params[k] if isinstance(params[k], list) else [params[k]]
for v in sorted(map(str, vs)):
pairs.append((str(k), v))
return urlencode(pairs)
def sign(method: str, path: str, query: dict, body: bytes, ts: str, nonce: str) -> str:
body_sha = hashlib.sha256(body).hexdigest()
source = "\n".join([
method.upper(), path, canonical_query(query),
APP_ID, API_KEY, ts, nonce, body_sha,
])
return hmac.new(API_SECRET.encode(), source.encode(), hashlib.sha256).hexdigest()
def request(method: str, path: str, query: dict | None = None, body: bytes = b""):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
headers = {
"X-App-Id": APP_ID,
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign(method, path, query or {}, body, ts, nonce),
"Content-Type": "application/json",
}
query_string = canonical_query(query or {})
url = BASE + path + (("?" + query_string) if query_string else "")
return requests.request(method, url, headers=headers, data=body, timeout=10, allow_redirects=False)
2.4 Signature test vectors
Check your implementation against these three vectors before calling the API: compare the signing string segment by segment, then the final signature. All three use app_id=app_demo, api_key=key_demo, api_secret=secret_demo, X-Timestamp=1789000000, X-Nonce=nonce_demo. Line breaks in the signing string are real newlines.
- QUERY
(empty)
- RAW_BODY
(empty)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Signing string
GET /api/v1/balance app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Expected X-Sign
21b0a67b99490232913d9bf2e1206593ab79285441465d925c26f3ff50c62912
- QUERY
(empty)
- RAW_BODY
{"merchant_order_no":"PM1","amount":"100.00","currency":"PKR","pay_method":"JAZZCASH"}
- SHA256_HEX(RAW_BODY)
0bf9b9e1505c66dbab60de357ffaff19a19e282eafc4a010f08135d42a0285de
- Signing string
POST /api/v1/payin/create app_demo key_demo 1789000000 nonce_demo 0bf9b9e1505c66dbab60de357ffaff19a19e282eafc4a010f08135d42a0285de
- Expected X-Sign
c769c86cb9289b60d5f185a55e472ea36e843451c7580d3ff060e810b1eca980
- QUERY
probe=A&probe=a+b&x=1
- RAW_BODY
(empty)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Signing string
GET /api/v1/balance probe=A&probe=a+b&x=1 app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Expected X-Sign
0dce9b3f6782a672447c55becb932a90be345f3309c50e39d7c8d32346d8fea2
2.5 Troubleshooting
20001: vector A failing means wrong segment order or separators; B failing means the body hash was not computed over the exact bytes sent; C failing means query canonicalisation differs, and JavaScript'sencodeURIComponentdoes not escape! ' ( ) *by default. If all three pass, check thatapi_secretbelongs to this environment and thatPATHis not the route template.- Intermittent
20001or timestamp errors: server clock drift beyond 300 seconds; sync with NTP. A repeatedX-Nonceis also rejected. 20003: your egress IP is not allowlisted; this check runs before signature verification. Behind NAT or across availability zones you may have several egress IPs; send all of them to support.
03 Create payin POST /api/v1/payin/create
3.1 Request
| Field | Type | Required | Description |
|---|---|---|---|
merchant_order_no | string(≤64) | yes | Your order number, unique per merchant; also the idempotency key |
amount | string(≤32) | yes | Amount in major units, e.g. "100.00" |
currency | string | yes | Currency code, e.g. PKR |
pay_method | string(2–32) | yes | Payment method, e.g. JAZZCASH, from the list issued at onboarding |
product_name | string(≤128) | no | Product name |
notify_url | string(≤512) | no | Notification URL for this order; falls back to your default payin notification URL |
return_url | string(≤512) | no | Browser redirect after payment; never treat the redirect as proof of payment |
payer_name | string(≤128) | no | Payer name |
payer_phone | string(≤32) | no | Payer mobile, 11 digits starting with 03 in Pakistan; required by some methods |
payer_id_no | string(≤64) | no | Payer ID number, 13-digit CNIC in Pakistan; required by direct-debit methods |
payer_email | string(≤128) | no | Payer email |
3.2 Response
| Field | Description |
|---|---|
payin_order_no | Platform order number, used for queries |
merchant_order_no | Your order number, echoed |
amount / currency | Echoed from the stored order |
pay_method | Payment method, e.g. JAZZCASH, EASYPAISA |
status | Order status, see 9.1 |
cashier_url | Platform cashier page; you can send the payer straight there |
pay_url | Upstream payment link; set only while PENDING |
qr_code | QR content to render as a payment QR; set only while PENDING |
channel_order_no | Payment reference, may be empty |
fail_code / fail_reason | Set only when FAIL; values in 9.2 |
{
"code": 0,
"message": "OK",
"data": {
"payin_order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"amount": "100.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"pay_method": "JAZZCASH",
"status": "PENDING",
"cashier_url": "https://cashier.example.com/orderPage/PO202609110001?t=…",
"pay_url": "https://pay.example.com/PO202609110001",
"qr_code": "",
"channel_order_no": "",
"fail_code": "",
"fail_reason": ""
},
"trace_id": "…",
"timestamp": 1789000000
}
PENDING: send the payer to pay_url or render qr_code, or open cashier_url. FAIL: read fail_code; to collect again, create a new order with a new merchant_order_no. INIT / PROCESSING: the upstream has not answered yet; poll the query endpoint or open cashier_url. Do not treat it as a failure.
04 Query payin GET /api/v1/payin/{payin_order_no}
The path parameter is the platform order number. The query result is the authoritative order state; notifications may repeat, arrive late or exhaust their retries, so confirm with a query.
| Field | Description |
|---|---|
payin_order_no / merchant_order_no | Platform / merchant order number |
status | Order status, see 9.1 |
amount / currency | Amount and currency |
pay_method | Payment method |
channel_order_no | Payment reference, may be empty |
pay_url / qr_code | Set only while PENDING |
notify_url / return_url | Echoed from the create request |
expire_time | Order expiry |
success_time | Time of success; empty otherwise |
fail_code / fail_reason | Set only when FAIL |
created_at | Creation time |
{
"payin_order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"status": "SUCCESS",
"amount": "100.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"pay_method": "JAZZCASH",
"channel_order_no": "JC8823717",
"pay_url": "",
"qr_code": "",
"notify_url": "https://merchant.example.com/notify",
"return_url": "",
"expire_time": "2026-09-11T08:30:00Z",
"success_time": "2026-09-11T08:05:12Z",
"fail_code": "",
"fail_reason": "",
"created_at": "2026-09-11T08:00:00Z"
}
05 Create payout POST /api/v1/payout/create
5.1 Request
| Field | Type | Required | Description |
|---|---|---|---|
merchant_order_no | string(≤64) | yes | Your order number, unique per merchant; also the idempotency key |
amount | string(≤32) | yes | Amount in major units |
currency | string | yes | Currency code |
pay_method | string(2–32) | yes | Payment method, from the list issued at onboarding |
receiver_name | string(≤128) | yes | Beneficiary name |
receiver_account | string(≤128) | yes | Beneficiary account: wallet number for wallet payouts (11 digits starting with 03 in Pakistan); bank account number or IBAN for bank payouts (PK + 22 characters in Pakistan) |
receiver_bank_code | string(≤64) | no | Bank code, required for bank payouts; use the supported-bank list issued at onboarding |
receiver_phone | string(≤32) | no | Beneficiary mobile, 11 digits starting with 03 in Pakistan; required by some bank payouts |
receiver_id_no | string(≤64) | no | Beneficiary ID number, 13-digit CNIC in Pakistan; required for wallet payouts |
receiver_email | string(≤128) | no | Beneficiary email; required by some methods |
If a beneficiary field required by the chosen method is missing, the response is 10001 and message names the field.
5.2 Response
Same shape as create payin: payout_order_no, merchant_order_no, amount, currency, pay_method, status, fail_code, fail_reason. On acceptance status is INIT; the outcome arrives by notification and query.
{
"payout_order_no": "PT202609110001",
"merchant_order_no": "W202609110001",
"amount": "500.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"status": "INIT",
"fail_code": "",
"fail_reason": ""
}
On acceptance the amount plus fee moves from available to frozen balance. It returns to available when the payout fails and is deducted from frozen when it succeeds. Orders are rejected when the available balance is insufficient.
06 Query payout GET /api/v1/payout/{payout_order_no}
| Field | Description |
|---|---|
payout_order_no / merchant_order_no | Platform / merchant order number |
status | Order status, see 9.1 |
amount / currency | Amount and currency |
pay_method | Payment method |
receiver_name / receiver_account / receiver_bank_code | Beneficiary, account masked |
success_time | Time of success; empty otherwise |
fail_code / fail_reason | Set only when FAIL |
created_at | Creation time |
{
"payout_order_no": "PT202609110001",
"merchant_order_no": "W202609110001",
"status": "SUCCESS",
"amount": "500.00",
"currency": "PKR",
"pay_method": "JAZZCASH",
"receiver_name": "Ali Khan",
"receiver_account": "0300****1234",
"receiver_bank_code": "",
"success_time": "2026-09-11T09:12:40Z",
"fail_code": "",
"fail_reason": "",
"created_at": "2026-09-11T09:10:00Z"
}
PENDING_VERIFY means the payout outcome is not yet confirmed and the funds stay frozen. Submitting again under a new order number can pay twice: keep the original order, wait for the notification or keep querying, and contact support with the platform order number if it stays unresolved.
07 Balances GET /api/v1/balance
No parameters. Returns your balances per currency. Total = available + frozen.
{
"as_of": "2026-09-11T08:00:00Z",
"balances": [
{
"currency": "PKR",
"available_balance": "12345.00",
"frozen_balance": "100.00",
"total_balance": "12445.00"
}
]
}
08 Notifications
When an order reaches SUCCESS or FAIL, the platform POSTs to your notification URL. Payin uses the order's notify_url, falling back to your default payin URL; payout uses your default payout URL. If no URL is configured nothing is sent; rely on queries.
8.1 Payload
{
"biz_type": "PAYIN",
"order_no": "PO202609110001",
"merchant_order_no": "M202609110001",
"amount": "100.00",
"currency": "PKR",
"status": "SUCCESS",
"fail_code": "",
"fail_reason": "",
"finished_at": "2026-09-11T08:05:12Z"
}
| Field | Description |
|---|---|
biz_type | PAYIN or PAYOUT |
order_no | Platform order number (payin_order_no or payout_order_no) |
merchant_order_no | Your order number |
amount / currency | Verify against your order |
status | SUCCESS or FAIL |
fail_code / fail_reason | Set on failure, see 9.2 |
finished_at | Time the order reached its final state |
8.2 Verification
Notifications carry the same 5 signature headers as your requests, signed per section 2 over the notification URL's actual path, query string and raw body. Verify before processing; you can reuse your own signing code:
import hmac, json, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/notify")
def on_notify():
body = request.get_data() # raw bytes; never parse and re-serialise
ts = request.headers.get("X-Timestamp", "")
nonce = request.headers.get("X-Nonce", "")
got = request.headers.get("X-Sign", "")
query = request.args.to_dict(flat=False) # keep every value of repeated keys
want = sign("POST", request.path, query, body, ts, nonce)
if not hmac.compare_digest(got, want):
return "invalid sign", 401
if abs(time.time() - int(ts or 0)) > 300:
return "expired", 401
handle_notification(json.loads(body)) # yours: verify order, amount, currency, then update
return "success", 200
8.3 Acknowledgement and retries
- After verifying and processing (or durably queuing) the notification, reply within 5 seconds with HTTP 200 and the plain-text body
success. Anything else, or a timeout, counts as failure; the URL must not redirect. - Failed deliveries are retried up to 3 times, after roughly 15 seconds, 1 minute and 5 minutes; retries stop after 30 minutes.
- Notifications may repeat or arrive out of order. Locate the order by
biz_type + order_no; do not process the same result twice; when a result differs from what you hold, query first, then update. - A missing notification does not mean failure. The query endpoint is authoritative; poll it with increasing intervals when no notification arrives.
09 Statuses and codes
9.1 Order status
| Value | Applies to | Meaning | What to do |
|---|---|---|---|
INIT | payin / payout | Order created | Wait for the notification or query |
PENDING | payin | Awaiting payment | Send the payer to the payment link or QR |
PROCESSING | payin / payout | Upstream processing | Wait for the notification or query; do not resubmit |
SUCCESS | payin / payout | Succeeded (final) | Verify order number, amount and currency, then update your order |
FAIL | payin / payout | Failed (final) | Use fail_code to decide whether to create a new order |
CLOSED | payin / payout | Closed or expired (final) | Stop using the old payment link; create a new order if still needed |
PENDING_VERIFY | payin / payout | Outcome unconfirmed | Neither success nor failure; keep querying. Never resubmit: on payouts this pays twice |
9.2 Failure code fail_code
Set when the order is FAIL. fail_reason is display text only; branch on fail_code. The set only grows; treat unknown values as UNSPECIFIED.
| fail_code | Meaning | Create a new order? |
|---|---|---|
CHANNEL_REJECTED | Rejected by the channel | Yes, with a new merchant_order_no |
PAYMENT_TIMEOUT | Not paid within the payment window | Yes, with a new merchant_order_no |
PENDING_MANUAL_REVIEW | Outcome unclear; platform is investigating | Do not retry: the upstream may already have debited or paid out; wait for a later notification or query |
CHANNEL_UNAVAILABLE | No payment service available | Retrying rarely helps; contact support |
ORDER_CLOSED | Order closed or cancelled | Yes, if still needed |
UNSPECIFIED | Unclassified failure | Query the original order first, then decide |
9.3 Response code
code describes this API call, not the order. Branch on code; do not parse message.
| code | HTTP | Meaning | What to do |
|---|---|---|---|
| 10000 | 500 | System error | Resend unchanged under the same order number, or query; if it persists contact the platform with trace_id |
| 10001 | 400 | Invalid parameter | Fix the parameter named in message and resend |
| 10002 | 404 | Not found | Check the platform order number and credentials, then query again |
| 10003 | 409 | Order number conflict | This merchant_order_no already exists with different content; query the original order, or create a new one with a new number |
| 10004 | 503 | Service unavailable | Back off, then resend unchanged under the same order number |
| 10005 | 400 | Currency scale not supported | amount has more decimals than the currency allows; the platform does not round |
| 10006 | 400 | Payment service not enabled | That pay_method is not enabled for you; contact support. Resending does not help |
| 10007 | 400 | Payment service temporarily unavailable | Usually the amount is outside the method's range; resend later unchanged, or use another enabled method |
| 20001 | 401 | Signature verification failed | Check against the vectors in 2.4 |
| 20003 | 403 | Access denied | Egress IP not allowlisted |
| 20004 | 403 | Merchant frozen or unavailable | Contact the platform |
| 20005 | 403 | Merchant disabled | Contact the platform |
| 20006 | 429 | Rate limited | Back off and retry |
10 Appendix: payment methods and banks
Payin currently supports JazzCash, Easypaisa and QR payments in Pakistan (PKR); payout supports wallet and bank transfers. The methods, currencies, channel codes and supported banks available to you are the ones issued at onboarding; do not guess bank codes.
pay_methodcarries the payment method alone: direction comes from the endpoint (/payin/createvs/payout/create) and the currency fromcurrency, which together identify exactly one payment channel. A bank name or bank code never replaces it. Create and query responses echo the samepay_method.pay_methodin create and query responses echoes the method actually used; it is not a request parameter.- Bank payouts:
receiver_bank_codeexactly as listed in the supported-bank list;receiver_accountis the bank account number or IBAN. - Wallet payouts:
receiver_accountis the wallet mobile number;receiver_id_nois usually required. - Before go-live, complete one payin and one payout with test credentials and verify that: resending under the same order number does not create a second order; a tampered notification fails verification; a repeated notification is not processed twice. Production credentials are issued separately after acceptance; test credentials must not be reused.