收付款
收款
创建收款单,引导用户到 HTP 收银台,并在服务端确认结果。
1. 创建收款单
用户下单时,在服务端创建收款单。
POST /open/v1/payments
Idempotency-Key: 5c1e0f8a2b9d4e7f{
"out_order_no": "A20261107-0001",
"amount": "40.00",
"currency": "USDT",
"description": "会员月卡",
"payer_sub": "2f6c0d1e8a9b4c7d",
"expire_minutes": 30
}| 字段 | 必填 | 说明 |
|---|---|---|
out_order_no |
✓ | 你的订单号,[A-Za-z0-9_-]{1,64},在环境内唯一 |
amount |
✓ | USDT,最多 2 位小数,必须大于手续费 |
currency |
✓ | USDT |
description |
在收银台展示给用户(最长 255) | |
payer_sub |
指定付款人。用户已登录时建议填写。 | |
expire_minutes |
1–1440,默认 30 |
响应为状态 CREATED 的订单对象,含 checkout_url:
{
"id": "0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d",
"out_order_no": "A20261107-0001",
"order_type": "PAYMENT",
"environment": "PRODUCTION",
"status": "CREATED",
"amount": "40.00",
"refunded_amount": "0.00",
"fee_amount": "0.40",
"net_amount": "39.60",
"currency": "USDT",
"user_sub": "2f6c0d1e8a9b4c7d",
"checkout_url": "https://app.htp.example/open/checkout?token=…",
"expires_at": "2026-11-07T09:30:00Z",
"created_at": "2026-11-07T09:00:00Z"
}把 id 与你的订单一起保存。此时可能返回 LIMIT_EXCEEDED、USER_NOT_AUTHORIZED(payer_sub 从未登录或已取消授权)、SELF_PAYMENT_FORBIDDEN、SANDBOX_USER_ONLY 等错误。
2. 引导用户到收银台
checkout_url 是 HTP 收银台,页面运行在 HTP App 内并使用用户的 HTP 登录态。在普通浏览器中打开无法获取登录态,会提示用户在 HTP App 中打开。常见做法:
- 桌面网页 —— 把
checkout_url展示为二维码,用户用 HTP App 扫码。 - 你的页面本身运行在 HTP App 内(从 HTP 打开的 H5)—— 直接跳转到
checkout_url。 - 手机网页或原生 App —— 把链接交给 HTP App 打开(二维码、分享,或 HTP 对接人提供的 App 链接)。
收银台展示你的 App 名称、Logo、开发者名称、金额和说明。用户按 amount 全额付款(付款方不承担手续费),并输入支付 PIN 确认。收银台不会跳回你的页面:用户关闭页面后回到你的 App。
checkout_url 相当于凭证
任何打开它的人都可以付款(除非设置了 payer_sub)。只展示给下单用户,不要写入日志。
3. 在服务端确认
只有在以下情况之一时才把订单标记为已支付:
- 收到并校验通过的
payment.paid回调,或 GET /open/v1/orders/{id}返回status: "PAID"。
用户在收银台期间,你的页面可以轮询你自己的后端;后端读取数据库(由回调更新),必要时再查询 HTP。永远不要相信客户端告诉你“已支付”。
收款单状态
| 状态 | 含义 | 后续 |
|---|---|---|
CREATED |
等待用户付款 | 支付、关闭或过期 |
PAID |
已支付,资金已入你的钱包(net_amount) |
发货;可退款 |
CLOSED |
你已关闭,或未支付已过期 | 终态 —— 需重新创建收款单 |
超过 expires_at 仍未支付的收款单显示为 CLOSED,不再返回 checkout_url。
关闭未支付的收款单
用户取消或购物车变化时关闭收款单,使旧链接无法再支付:
POST /open/v1/payments/0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d/close无请求体,不需要 Idempotency-Key。关闭已关闭的订单无副作用;关闭已支付的订单返回 INVALID_STATE。
资金流向
| 付款人钱包 | 你的钱包 | |
|---|---|---|
| 收款 | − amount |
+ net_amount(amount − fee_amount) |
| 退款 | + 退款金额 | − 退款金额(手续费不退) |
| 付款 | + 金额 | − 金额 |
fee_amount = round_half_up(amount × fee_rate) + fee_fixed(按分计算)。手续费不小于金额时创建失败,返回 INVALID_AMOUNT。
限额(单笔、日、月 —— 按 UTC)在创建时检查,在用户确认付款时还会再检查一次,如果期间有其他订单完成,收银台可能返回 LIMIT_EXCEEDED。
完整示例
import crypto from 'node:crypto';
import { htp } from './htp.js';
export async function startCheckout(order, user) {
// Persist the key with the order so retries reuse it
order.idempotencyKey ??= crypto.randomBytes(16).toString('hex');
const payment = await htp(
'POST',
'/open/v1/payments',
{
out_order_no: order.number,
amount: order.total, // "40.00" — a string
currency: 'USDT',
description: order.title,
payer_sub: user.htpSub,
},
order.idempotencyKey,
);
await db.orders.update(order.id, { htpPaymentId: payment.id, status: 'AWAITING_PAYMENT' });
return payment.checkout_url;
}import secrets
from htp import htp
def start_checkout(order, user):
order.idempotency_key = order.idempotency_key or secrets.token_hex(16)
payment = htp("POST", "/open/v1/payments", {
"out_order_no": order.number,
"amount": order.total, # "40.00" — a string
"currency": "USDT",
"description": order.title,
"payer_sub": user.htp_sub,
}, order.idempotency_key)
order.htp_payment_id = payment["id"]
order.status = "AWAITING_PAYMENT"
order.save()
return payment["checkout_url"]type Order struct {
ID string `json:"id"`
Status string `json:"status"`
CheckoutURL string `json:"checkout_url"`
}
func StartCheckout(ctx context.Context, c *htp.Client, o *MyOrder, payerSub string) (string, error) {
var p Order
err := c.Do(ctx, "POST", "/open/v1/payments", map[string]any{
"out_order_no": o.Number,
"amount": o.Total, // "40.00"
"currency": "USDT",
"description": o.Title,
"payer_sub": payerSub,
}, o.IdempotencyKey, &p)
if err != nil {
return "", err
}
o.HTPPaymentID = p.ID
return p.CheckoutURL, saveOrder(ctx, o)
}// HtpClient from /docs/authentication; JSON with Jackson
private final ObjectMapper mapper = new ObjectMapper();
public String startCheckout(MyOrder order, String payerSub) throws Exception {
if (order.idempotencyKey == null) {
// Persist the key with the order so retries reuse it
order.idempotencyKey = UUID.randomUUID().toString().replace("-", "");
}
String body = mapper.writeValueAsString(Map.of(
"out_order_no", order.number,
"amount", order.total, // "40.00" — a string
"currency", "USDT",
"description", order.title,
"payer_sub", payerSub));
HttpResponse<String> res = htp.send("POST", "/open/v1/payments", body, order.idempotencyKey);
JsonNode json = mapper.readTree(res.body());
if (res.statusCode() != 200) {
throw new IllegalStateException(json.path("error_code").asText() + " (trace " + json.path("trace_id").asText() + ")");
}
JsonNode payment = json.get("data");
order.htpPaymentId = payment.get("id").asText();
order.status = "AWAITING_PAYMENT";
orders.save(order);
return payment.get("checkout_url").asText();
}