收付款

收款

创建收款单,引导用户到 HTP 收银台,并在服务端确认结果。

用户 / HTP App你的服务端HTP 开放 API1. 用户在你的 App 下单2. POST /open/v1/payments3. checkout_url4. 在 HTP App 中打开 checkout_url5. 输入支付 PIN 确认6. 回调 payment.paid(已签名)7. 验签、标记已支付、返回 2xx8. 展示支付成功

1. 创建收款单

用户下单时,在服务端创建收款单。

http
POST /open/v1/payments
Idempotency-Key: 5c1e0f8a2b9d4e7f
json
{
  "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:

json
{
  "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. 在服务端确认

只有在以下情况之一时才把订单标记为已支付:

用户在收银台期间,你的页面可以轮询你自己的后端;后端读取数据库(由回调更新),必要时再查询 HTP。永远不要相信客户端告诉你“已支付”。

收款单状态

状态 含义 后续
CREATED 等待用户付款 支付、关闭或过期
PAID 已支付,资金已入你的钱包(net_amount) 发货;可退款
CLOSED 你已关闭,或未支付已过期 终态 —— 需重新创建收款单

超过 expires_at 仍未支付的收款单显示为 CLOSED,不再返回 checkout_url。

关闭未支付的收款单

用户取消或购物车变化时关闭收款单,使旧链接无法再支付:

http
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。

完整示例

Node.js
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;
}
Python
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"]
Go
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)
}
Java
// 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();
}