Titan Pay · Merchant API

商户接口对接文档

代收、代付、账户与回调共用一套地址和一套签名。请求只有 POST JSON,金额一律用分,回调只认确认正文。照着本页,可以从沙箱一路跑到生产。

  • 接口版本 2026-03-19
  • POST · JSON
  • HMAC-SHA256
  • 金额单位:分
本页目录
  1. 概览
  2. 接入准备
  3. 鉴权与签名
  4. 代收
  5. UTR 与 UPI
  6. 代付
  7. 账户与沙盒
  8. 回调通知
  9. 错误码
  10. 上线检查

01概览

Titan Pay 的商户接口分四组:代收、代付、账户和回调。身份是 X-Titan-Key 加一把密钥,所有业务接口都走同一个 Host,只接受 POST 和 JSON。

代收 · Collect

创建一笔 UPI 收款,拿到付款页地址或 upi:// 意图链接。到账结果通过回调和查询确认。

POST /v1/collect

代付 · Payout

把余额提到印度银行卡,创建即冻结余额。卡号响应只返回后四位。

POST /v1/payout

账户 · Account

查询当前模式的可用、冻结和总余额;沙盒里可以把订单推进到指定终态。

POST /v1/balance

回调 · Callback

订单进入终态后,平台向你的 callbackUrl 发 POST。验签、回确认正文、按平台订单号做幂等。

POST {callbackUrl}

先记住四条约定

  1. 身份在请求头。 X-Titan-Key、X-Titan-Timestamp、X-Titan-Nonce、X-Titan-Signature 四个字段不进 JSON;密钥由 X-Titan-Key 定位。
  2. 金额永远是整数分。 100.00 卢比写成 10000,不接受小数、字符串或科学计数法。
  3. 幂等键是商户订单号。 超时或断网时保持 orderNo 不变重试,换一个新的 X-Titan-Nonce,不要新建订单号。
  4. 回调是平台调你。 只为 PAID、FAILED、RETURNED、AMOUNT_MISMATCH 发送;PENDING、UNPAID、EXPIRED 不发。

02接入准备

开通后你会拿到一组商户资料。联调与生产共用同一把密钥,平台切换模式后,原来的密钥自动操作正式账套。

开通时会拿到的商户资料
资料示例用途
X-Titan-Key1042请求头里的商户编号,系统用它定位密钥。
接口密钥sk_****HMAC-SHA256 的密钥。只在服务端保存,不要写进前端或日志。
接入地址https://open.titantg.in沙箱与生产共用。开通时如果下发的是其他 Host,替换本页示例即可,路径与签名不变。
商户后台https://console.titantg.in/用开通时下发的登录账号。首次登录需要绑定动态口令(Google Authenticator、Authy、1Password 等),之后每次登录都要输入 6 位口令。
默认回调地址https://merchant.example/notify下单不传 callbackUrl 时使用;两处都为空则不发回调。
回调确认ok回调送达后返回纯文本 ok;其它正文会触发重试。
限额与白名单单笔 / 单日 / IP白名单为空表示不限制来源 IP;限额按签约配置,超限返回 AMOUNT_OUT_OF_RANGE 或 DAILY_LIMIT。

从沙箱到生产

  1. 在 Telegram 领取沙箱密钥,先调用 余额查询 验证签名,响应里 mode 应为 test。
  2. 创建一笔 1 卢比的代收,用 沙盒模拟 把它推进到 PAID,确认回调、验签和入账逻辑都能跑通。
  3. 把失败路径也跑一遍:FAILED、AMOUNT_MISMATCH、回调超时后主动查单。
  4. 确认费率、单笔/单日限额、结算批次和 IP 白名单,再由平台把商户切到生产。

切换模式后面不同。 沙箱订单和正式订单互不可见,沙盒模拟接口在生产模式会被拒绝。切换前把联调数据清理掉,不要用测试订单号跑正式流量。

03鉴权与签名

每个请求带四个请求头。签名是对五行规范串做 HMAC-SHA256,输出小写十六进制。任何一个空格、字段顺序或时间戳变化,签名都会失效。

请求头
请求头类型说明
X-Titan-Key必填string商户编号。商户不存在与签名错误都返回 SIGNATURE_INVALID,响应正文相同,不能用它探测编号是否存在。
X-Titan-Timestamp必填stringUnix 秒级时间戳的十进制字符串。与服务器时间相差超过 300 秒返回 TIMESTAMP_EXPIRED。
X-Titan-Nonce必填string8–64 位,只允许字母、数字、下划线 _ 和中划线 -。同一商户 10 分钟内重复使用返回 NONCE_REPLAYED。它不能替代商户订单号做幂等。
X-Titan-Signature必填string规范串的 HMAC-SHA256,小写十六进制,长度 64。大写十六进制视为签名错误。

规范串

五行用换行符 \n 连接,最后一行后面不加换行:

Canonical stringtext
HTTP_METHOD
PATH
TIMESTAMP
NONCE
SHA256_HEX(rawBody)
  • HTTP_METHOD 大写,固定为 POST。
  • PATH 只取路径,不带域名和查询串,例如 /v1/collect。
  • TIMESTAMP、NONCE 与请求头中的值逐字符一致。
  • rawBody 是实际发送的原始字节。先序列化再签名,发送时不要重新格式化,也不要加 BOM。
  • 请求体最大 64 KiB,且必须是 JSON 对象;Content-Type 以 application/json 开头。

可复核的签名样本

下面这组输入可以在本地复算,密钥 test-secret 只用于说明算法,不是任何商户的真实密钥。

签名样本输入与输出
输入值
密钥test-secret
请求体{}
X-Titan-Timestamp1760140800
X-Titan-Noncen-example1
PATH/v1/balance
期望签名2e6150e3633d0d2d682a08d4585375fefdef1d9bbb8159e0f3b6787aceacdb54
规范串 · /v1/balancetext
POST
/v1/balance
1760140800
n-example1
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a

服务端签名示例

Python 3sign.py
import hashlib, hmac, json, time, uuid

MERCHANT_NO = "1042"
SECRET = "test-secret"
path = "/v1/collect"

body = {
    "orderNo": "M202610110001",
    "amount": 10000,
    "callbackUrl": "https://merchant.example/notify",
}
raw = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()

ts = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
canonical = "\n".join([
    "POST", path, ts, nonce,
    hashlib.sha256(raw).hexdigest(),
])
signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
Node.js 18+sign.mjs
import crypto from "node:crypto";

const secret = "test-secret";
const path = "/v1/collect";
const body = { orderNo: "M202610110001", amount: 10000 };
const raw = JSON.stringify(body);

const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(8).toString("hex");
const canonical = [
  "POST", path, ts, nonce,
  crypto.createHash("sha256").update(raw).digest("hex"),
].join("\n");

const signature = crypto
  .createHmac("sha256", secret)
  .update(canonical)
  .digest("hex");

签名必须对应原始字节。 示例里为了让 curl 可读,JSON 是单行;正式代码不要把「用于签名的 rawBody」和「实际发送的 body」分开生成两次,否则很容易因为空格或字段顺序对不上。

用固定样本调一次

下面这条请求用的是上面的固定时间戳,线上会返回 TIMESTAMP_EXPIRED,只适合本地核对签名实现。真实请求换成当前秒级时间戳。

curl · 签名自检bash
curl -sS https://open.titantg.in/v1/balance \
  -H 'Content-Type: application/json; charset=UTF-8' \
  -H 'X-Titan-Key: 1042' \
  -H 'X-Titan-Timestamp: 1760140800' \
  -H 'X-Titan-Nonce: n-example1' \
  -H 'X-Titan-Signature: 2e6150e3633d0d2d682a08d4585375fefdef1d9bbb8159e0f3b6787aceacdb54' \
  -d '{}'
签名相关的常见错误
错误码HTTP通常原因
SIGNATURE_INVALID401X-Titan-Key 不存在,或签名与规范串不一致。先核对密钥、PATH 和 rawBody。
TIMESTAMP_EXPIRED401时间戳与服务器相差超过 300 秒。检查服务器时钟和时区。
NONCE_REPLAYED40110 分钟内复用了同一个 X-Titan-Nonce。重试时一定换新随机串。

04代收

代收负责「收款」:创建订单拿付款页或 UPI 意图链接,支付结果通过回调或查询确认。创建成功只代表受理,此时状态是 PENDING。

POST /v1/collect 代收下单

创建代收订单

按商户订单号创建一笔代收,返回平台订单号、付款页地址和 UPI 意图链接。同一商户、同一密钥、同一商户订单号重复提交时,返回第一次的结果,不会重复插入订单。

请求参数

代收下单请求参数
字段类型说明
orderNo必填string商户订单号,1–64 位,只允许字母、数字、_、-。同一商户下唯一,是代收的幂等键。
amount必填integer订单金额,单位分,大于 0 的整数。超出商户限额返回 AMOUNT_OUT_OF_RANGE。
callbackUrlstring该笔订单的回调地址,优先于商户默认地址。留空时用默认回调地址。
redirectUrlstring浏览器回跳地址。平台只保存,不会向该地址发回调。

响应字段

代收下单响应字段
字段类型说明
codestring结果码,成功固定为 OK。
messagestring结果说明,成功固定为 success。
data.orderNostring商户订单号,与请求一致。
data.tradeNostring平台订单号,前缀 TX。入账幂等请用它。
data.statusstring创建成功时为 PENDING。
data.amountinteger订单金额,单位分。
data.payUrlstring付款页地址。尚未生成时为空字符串。
data.vpastring收款 UPI。商户关闭回传或尚未生成时为空字符串。
data.intentUristringUPI 意图链接(upi://pay?...),没有收款 UPI 时为空字符串。
请求 · POST /v1/collectjson
{
  "orderNo": "M202610110001",
  "amount": 10000,
  "callbackUrl": "https://merchant.example/notify",
  "redirectUrl": "https://merchant.example/return"
}
响应 · 200 OKjson
{
  "code": "OK",
  "message": "success",
  "data": {
    "orderNo": "M202610110001",
    "tradeNo": "TX17601408001234ab",
    "status": "PENDING",
    "amount": 10000,
    "payUrl": "https://pay.example/cashier/TX17601408001234ab",
    "vpa": "merchant@upi",
    "intentUri": "upi://pay?pa=merchant%40upi&am=100.00&cu=INR"
  }
}

幂等与重试

  • 幂等键是 orderNo,不是 X-Titan-Nonce。重复请求必须换新 X-Titan-Nonce,否则先返回 NONCE_REPLAYED。
  • 命中已有订单时 HTTP 仍为 200,正文与第一次完全相同,并带响应头 Idempotent-Replayed: true。
  • 超时不要换订单号。保持单号不变重试,直到拿到平台订单号。

通知地址规则

  • 请求里的 callbackUrl 优先;未传时用商户默认回调地址;两处都为空,订单能创建但不会发回调。
  • 可以是域名或公网 IP,http 或 https,端口不限。
  • 不能带账号、密码或查询串,不能是本机或私网地址。
POST /v1/collect/query 代收查询

查询代收订单

按商户订单号或平台订单号查询。两个都传时必须指向同一笔订单,否则返回 ORDER_NOT_FOUND。联调只能查测试订单,生产只能查正式订单。

代收查询请求参数
字段类型说明
orderNostring商户订单号。与平台订单号至少传一个。
tradeNostring平台订单号。与商户订单号至少传一个。
代收查询响应字段
字段类型说明
data.statusstring对外状态,见下方状态表。
data.amountinteger订单金额,单位分。
data.paidAmountinteger实付金额,单位分。未成功时为 0。
data.bankRefstring银行 UTR。未成功时为空字符串。
data.payUrlstring付款页地址,尚未生成时为空字符串。
data.vpa / data.intentUristring收款 UPI 与意图链接,可能为空字符串。

代收状态

  • PENDING 已受理,尚未得到支付结果。
  • PAID 已成功,发送回调。
  • FAILED 未成功,发送回调。
  • UNPAID 尚未确认支付。不是失败,不发回调。
  • AMOUNT_MISMATCH 实付金额与订单金额不一致,发送回调。
  • EXPIRED 已超时未支付,不发回调。

静默不等于失败。 没收到终态回调时先查单。PENDING、UNPAID、EXPIRED 本来就不会发通知;把它们当失败去重建订单,会产生重复支付。

05UTR 与 UPI

银行侧已经到账、但回调丢失或人工补录时,用 UTR 接口核对与补单;不确定一个 UPI 是否属于本平台时,用归属查询。

POST /v1/utr/lookup UTR 查询

查询 UTR 是否已被订单占用

只看银行参考号,不需要商户订单号。占用订单属于当前商户、当前模式时,返回订单号、状态和金额;否则只返回 CLAIMED。

UTR 查询参数与响应
字段类型说明
bankRef必填string请求参数。银行参考号,8–32 位字母或数字。
data.statusstring响应。NOT_FOUND 表示没有订单占用,CLAIMED 表示已被占用。
data.kindstring能返回本商户订单时为 collect 或 payout,否则为空字符串。
data.orderNostring本商户订单号;看不到该订单时为空字符串。
data.orderStatusstring订单对外状态;看不到该订单时为空字符串。
data.orderAmountinteger订单金额,单位分;看不到该订单时为空字符串。
data.paidAmountinteger该 UTR 的实付金额;未成功为 0;看不到该订单时为空字符串。
POST /v1/utr/attach UTR 补单

把 UTR 补到订单上

请求体是 orderNo 与 bankRef,成功时返回代收订单对象。

  • 联调:可以把尚未成功的测试代收补成 PAID。
  • 生产:只能给已经成功的订单补 UTR,不能把未成功订单改成成功。
  • 订单已带本次 UTR:返回同一结果;UTR 被其他订单占用或本订单已有另一个 UTR:返回 BANK_REF_CLAIMED。
  • 订单不属于当前商户或当前模式:返回 ORDER_NOT_FOUND;当前状态不能补单:返回 STATE_CONFLICT。
POST /v1/vpa/lookup UPI 归属

确认 UPI 是否属于本平台

请求体为 {"vpa": "name@bank"},响应 data.owned 为布尔值:true 表示属于本平台,false 表示不属于。

06代付

代付负责「出款」:创建即冻结当前模式下的可用余额,成功后扣减。可用余额不足返回 INSUFFICIENT_BALANCE,联调账户未开通返回 ACCOUNT_NOT_FOUND。

POST /v1/payout 代付下单

创建代付订单

校验通过后返回 CREATED;同一商户订单号再次提交返回第一次的结果。

代付下单请求参数
字段类型说明
orderNo必填string规则与代收相同,是代付的幂等键,同一商户下唯一。
amount必填integer代付金额,单位分,大于 0 的整数。
ifsc必填string收款行 IFSC,必须匹配 ^[A-Z]{4}0[A-Z0-9]{6}$,例如 HDFC0001234。
holderName必填string持卡人姓名,去掉首尾空白后 1–64 位。
cardNumber必填string银行卡号,9–18 位数字,不要加空格。响应只返回后四位。
callbackUrlstring回调地址,规则与代收一致。
代付下单响应字段
字段类型说明
data.statusstring创建成功时为 CREATED。
data.amountinteger代付金额,单位分。
data.availableinteger当前模式的可用余额,单位分。
data.tradeNostring平台订单号,前缀 TX。
请求 · POST /v1/payoutjson
{
  "orderNo": "P202610110001",
  "amount": 10000,
  "ifsc": "HDFC0001234",
  "holderName": "Rahul",
  "cardNumber": "123456789012",
  "callbackUrl": "https://merchant.example/payout-notify"
}
响应 · 200 OKjson
{
  "code": "OK",
  "message": "success",
  "data": {
    "orderNo": "P202610110001",
    "tradeNo": "TX17601408001234ab",
    "status": "CREATED",
    "amount": 10000,
    "available": 89170
  }
}
POST /v1/payout/query 代付查询

查询代付订单

查询代付订单,响应里的卡号只有后四位。

代付查询响应字段
字段类型说明
data.statusstringCREATED、PROCESSING、PAID、FAILED 或 RETURNED。
data.amountinteger代付金额,单位分。
data.paidAmountinteger实付金额,未成功时为 0。
data.bankRefstring银行 UTR,未成功时为空字符串。
data.holderNamestring持卡人姓名。
data.cardMaskstring卡号后四位。

CREATED 已创建 · PROCESSING 处理中 · PAID 成功 · FAILED 失败 · RETURNED 未成功,金额退回可用余额。

07账户与沙盒

余额接口用来确认密钥、模式和账套;沙盒模拟接口用来在没有真实付款的情况下把测试订单推进到终态,验证回调链路。

POST /v1/balance 余额查询

查询当前模式的余额

请求体必须是空对象 {},空字符串不是合法请求体。联调读测试余额,生产读正式余额,两套余额不会混用。

余额查询响应字段
字段类型说明
data.merchantNostring商户编号。
data.modestringtest 测试账户,live 正式账户。
data.availableinteger可用余额,单位分。
data.frozeninteger冻结余额,单位分。
data.balanceinteger总余额,等于可用加冻结。
响应 · 200 OKjson
{
  "code": "OK",
  "message": "success",
  "data": {
    "merchantNo": "1042",
    "mode": "test",
    "available": 89170,
    "frozen": 10830,
    "balance": 100000
  }
}
POST /v1/sandbox/advance 沙盒模拟

推进测试订单到终态

只在联调模式可用,且只能操作本商户的测试订单;生产模式返回 MODE_FORBIDDEN。请求体为 kind(collect / payout)、orderNo、result(success / failure)。

沙盒模拟结果
kindresult订单终态
collectsuccessPAID,随后发送回调。
collectfailureFAILED,随后发送回调。
payoutsuccessPAID,随后发送回调。
payoutfailureRETURNED,随后发送回调。

同一结果重复调用返回 200 和当前结果,不会再次回调;已经是相反结果时返回 STATE_CONFLICT。

08回调通知

回调是平台调用商户,不是商户调用平台。订单进入终态后,平台向该笔订单的 callbackUrl 发 POST;你只需要回一段约定的确认正文。

何时发送

  • 只为 PAID、FAILED、RETURNED、AMOUNT_MISMATCH 发送,代付的退回和金额不符也会回调。
  • PENDING、UNPAID、EXPIRED 不发通知。
  • 同一次结果不会因为重复查询而再次发送;只有把订单推进到相反终态才会产生另一条通知。

通知字段

回调请求体字段
字段类型说明
kind必填stringcollect 或 payout。
merchantNo必填string商户编号。
orderNo必填string商户订单号。
tradeNo必填string平台订单号。入账幂等请用它。
status必填stringPAID、FAILED、RETURNED 或 AMOUNT_MISMATCH。
amount必填integer订单金额,单位分。
paidAmount必填integer实付金额,单位分。失败或退回时为 0。
bankRefstring银行 UTR,失败时为空字符串。
vpastring收款 UPI。商户关闭回传时为空字符串。

验签

平台用该商户的接口密钥,按与商户请求相同的算法签名。PATH 取通知地址的路径,请求头仍是 X-Titan-Key、X-Titan-Timestamp、X-Titan-Nonce、X-Titan-Signature。请用自己保存的密钥验签,并确认 X-Titan-Key 是自己的编号。

确认正文

  • 只比较响应正文:去掉首尾空白后等于 ok,即视为已送达;不看 HTTP 状态码。
  • 返回纯文本 ok,不要另包一层 JSON。
  • 没有返回 ok、超时或连接失败时,会按覆盖 24 小时的退避重试,然后进入死信。
  • 确认要做幂等:同一 tradeNo、同一状态的重复通知不要重复入账。
平台 → 商户 · 通知http
POST /notify HTTP/1.1
Host: merchant.example
Content-Type: application/json; charset=UTF-8
X-Titan-Key: 1042
X-Titan-Timestamp: 1760140800
X-Titan-Nonce: n-callback1
X-Titan-Signature: <lowercase-hex-hmac>

{
  "kind": "collect",
  "merchantNo": "1042",
  "orderNo": "M202610110001",
  "tradeNo": "TX17601408001234ab",
  "status": "PAID",
  "amount": 10000,
  "paidAmount": 10000,
  "bankRef": "123456789012",
  "vpa": "merchant@upi"
}
商户 → 平台 · 确认text
ok

没收到终态通知先查单。 静默不等于失败。把 PENDING、UNPAID、EXPIRED 当成失败来重建订单,会造成重复支付;用 查询接口 确认后再决定。

09错误码

失败时按 code 分支判断,message 只用于排查和日志。错误码与 HTTP 状态一一对应。

失败响应json
{
  "code": "AMOUNT_INVALID",
  "message": "invalid amount",
  "data": null,
  "requestId": "8f2c1d7a9b4e4f0e"
}
错误码一览
codeHTTP含义
INVALID_REQUEST400请求方法、Content-Type、JSON 或字段格式不正确。
SIGNATURE_INVALID401商户编号不存在,或签名与规范串不一致。
TIMESTAMP_EXPIRED401时间戳与服务器相差超过 300 秒。
NONCE_REPLAYED401该 X-Titan-Nonce 在 10 分钟内已经用过。
MERCHANT_DISABLED403商户已禁用,不能调用接口。
IP_NOT_ALLOWED403来源 IP 不在该商户的白名单中。白名单为空表示不校验。
MODE_FORBIDDEN403生产模式调用了只在联调可用的接口(如沙盒模拟)。
ORDER_NOT_FOUND404订单不属于当前商户或当前模式。
ACCOUNT_NOT_FOUND404测试账户尚未开通。
BANK_REF_NOT_FOUND404UTR 没有记录。
STATE_CONFLICT409订单已经是另一个终态,当前操作不允许。
BANK_REF_CLAIMED409UTR 已被占用,或本订单已有另一个 UTR。
BODY_TOO_LARGE413请求体超过 64 KiB。
AMOUNT_INVALID422金额不是大于 0 的整数分。
AMOUNT_OUT_OF_RANGE422金额超出商户已配置的限额。
INSUFFICIENT_BALANCE422可用余额不足。
CALLBACK_URL_INVALID422回调地址格式不合法。
DAILY_LIMIT422超出日限额。
CHANNEL_REJECTED422通道拒绝该笔请求。
SYSTEM_BUSY429请求过于频繁。
INTERNAL_ERROR500平台内部错误。

重试策略

  • 429 与 5xx 可以退避重试,保持 orderNo 不变,并换新 X-Titan-Nonce。
  • 其余 4xx 是请求本身的问题,重试不会成功,按 message 和 code 修字段。
  • SIGNATURE_INVALID 不要暴露给付款人,也不要在响应里回显密钥或签名串。

10上线检查

生产切换前,把下面几项逐条跑通。任何一项没确认,都建议继续留在沙箱。

  • 用页面上的 test-secret 样本复算出同一个签名,确认编码、换行和时间戳都正确。
  • 所有金额都按整数分发送,数据库里没有用浮点数存金额。
  • 超时重试保持同一个 orderNo,每次换新 X-Titan-Nonce,并正确处理 Idempotent-Replayed。
  • 回调先验签,再按 tradeNo + status 做幂等,重复通知不会重复入账。
  • 回调返回纯文本 ok;测过错误正文会触发重试。
  • 回调静默或超时时先查单,不重建订单;UNPAID 不当失败处理。
  • IP 白名单、单笔/单日限额、默认回调地址和确认正文已在商户资料里确认。
  • 先按 5% 流量灰度,盯成功率和回调延迟,再按 20% 递增;结算文件与 UTR 逐笔对得上。

联调卡住了? 把商户编号、tradeNo、请求时间、code 与 message 一起发到 @titantg_chan,不要发密钥和完整签名串。Telegram 值班通常 2 小时内回复。