收付款

回调

接收并校验签名的 payment.paid、payment.refunded、payout.completed 事件。

订单到达终态时,HTP 向你在控制台设置的回调地址 POST 一个 JSON 事件。

事件

event 触发时机 data
payment.paid 用户已支付 收款单,status: "PAID",user_sub 已填写
payment.refunded 退款成功 退款单:order_type: "REFUND",parent_id = 收款单 id
payout.completed 付款成功 付款单

收款单过期或被你关闭时不会发送回调。

请求

http
POST /hooks/htp?shop=1 HTTP/1.1
Host: shop.example.com
Content-Type: application/json
X-Event: payment.paid
X-Timestamp: 1730970312
X-Nonce: 3q2-7wEAAAB8n9Kx1aBcDg
Idempotency-Key: 9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b
X-Signature: 1b2Yt…=
json
{
  "event_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "event": "payment.paid",
  "created_at": "2026-11-07T09:05:12Z",
  "data": {
    "id": "0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d",
    "out_order_no": "A20261107-0001",
    "order_type": "PAYMENT",
    "environment": "PRODUCTION",
    "status": "PAID",
    "amount": "40.00",
    "refunded_amount": "0.00",
    "fee_amount": "0.40",
    "net_amount": "39.60",
    "currency": "USDT",
    "description": "会员月卡",
    "user_sub": "2f6c0d1e8a9b4c7d",
    "expires_at": "2026-11-07T09:30:00Z",
    "completed_at": "2026-11-07T09:05:12Z",
    "created_at": "2026-11-07T09:00:00Z"
  }
}

校验签名

签名规则与 API 请求相同,密钥为回调签名密钥:

text
string_to_sign = "POST"                  + "\n" +
                 回调地址的路径和查询串   + "\n" +
                 X-Timestamp             + "\n" +
                 X-Nonce                 + "\n" +
                 Idempotency-Key         + "\n" +   // = event_id
                 hex(sha256(原始请求体))

X-Signature = base64( HMAC-SHA256( callback_secret, string_to_sign ) )
  • 使用控制台中配置的回调地址的路径和查询串(上例为 /hooks/htp?shop=1;地址没有路径时为 /)。如果反向代理会改写路径,请从配置中读取,而不是从收到的请求中取。
  • 在解析 JSON 之前对原始请求体字节求哈希。
  • 使用常量时间比较,并拒绝与当前时间相差超过 5 分钟的时间戳。
Node.js
import crypto from 'node:crypto';
import express from 'express';

const CALLBACK_SECRET = process.env.HTP_CALLBACK_SECRET;
const CALLBACK_URL = new URL(process.env.HTP_CALLBACK_URL); // as set in the console
const CALLBACK_PATH = CALLBACK_URL.pathname + CALLBACK_URL.search;

const app = express();

app.post(CALLBACK_URL.pathname, express.raw({ type: '*/*' }), async (req, res) => {
  const ts = req.get('X-Timestamp') ?? '';
  const nonce = req.get('X-Nonce') ?? '';
  const eventId = req.get('Idempotency-Key') ?? '';
  const given = Buffer.from(req.get('X-Signature') ?? '', 'base64');

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
  const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = crypto
    .createHmac('sha256', CALLBACK_SECRET)
    .update(['POST', CALLBACK_PATH, ts, nonce, eventId, bodyHash].join('\n'))
    .digest();
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // acknowledge fast…
  await handleEvent(event); // …then do the work (or enqueue it)
});
Python
import base64, hashlib, hmac, json, os, time
from urllib.parse import urlsplit
from flask import Flask, request, abort

CALLBACK_SECRET = os.environ["HTP_CALLBACK_SECRET"].encode()
u = urlsplit(os.environ["HTP_CALLBACK_URL"])  # as set in the console
CALLBACK_PATH = (u.path or "/") + (f"?{u.query}" if u.query else "")

app = Flask(__name__)

@app.post(u.path or "/")
def htp_callback():
    raw = request.get_data()  # raw bytes, before parsing
    ts = request.headers.get("X-Timestamp", "")
    nonce = request.headers.get("X-Nonce", "")
    event_id = request.headers.get("Idempotency-Key", "")
    given = request.headers.get("X-Signature", "")

    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(401)
    sts = "\n".join(["POST", CALLBACK_PATH, ts, nonce, event_id, hashlib.sha256(raw).hexdigest()])
    expected = base64.b64encode(hmac.new(CALLBACK_SECRET, sts.encode(), hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, given):
        abort(401)

    enqueue(json.loads(raw))  # process asynchronously
    return "", 200
Go
func HTPCallback(secret []byte, callbackPath string, handle func(Event) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		ts := r.Header.Get("X-Timestamp")
		sec, err := strconv.ParseInt(ts, 10, 64)
		if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
			http.Error(w, "stale", http.StatusUnauthorized)
			return
		}
		sum := sha256.Sum256(raw)
		sts := strings.Join([]string{"POST", callbackPath, ts, r.Header.Get("X-Nonce"),
			r.Header.Get("Idempotency-Key"), hex.EncodeToString(sum[:])}, "\n")
		mac := hmac.New(sha256.New, secret)
		mac.Write([]byte(sts))
		given, _ := base64.StdEncoding.DecodeString(r.Header.Get("X-Signature"))
		if !hmac.Equal(given, mac.Sum(nil)) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		var ev Event
		if err := json.Unmarshal(raw, &ev); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		if err := handle(ev); err != nil { // idempotent, quick
			http.Error(w, "retry", http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusOK)
	}
}
PHP
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
$eventId = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
$given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

$u = parse_url(getenv('HTP_CALLBACK_URL')); // as set in the console
$path = ($u['path'] ?? '/') . (isset($u['query']) ? '?' . $u['query'] : '');

if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}
$sts = implode("\n", ['POST', $path, $ts, $nonce, $eventId, hash('sha256', $raw)]);
$expected = base64_encode(hash_hmac('sha256', $sts, getenv('HTP_CALLBACK_SECRET'), true));
if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
enqueue($event);
http_response_code(200);
Java
// Spring Boot
@RestController
public class HtpCallbackController {
    private final byte[] secret = System.getenv("HTP_CALLBACK_SECRET").getBytes(StandardCharsets.UTF_8);
    private final String callbackPath; // path + query of the callback URL set in the console

    public HtpCallbackController() {
        URI u = URI.create(System.getenv("HTP_CALLBACK_URL"));
        String path = (u.getRawPath() == null || u.getRawPath().isEmpty()) ? "/" : u.getRawPath();
        this.callbackPath = u.getRawQuery() == null ? path : path + "?" + u.getRawQuery();
    }

    @PostMapping(value = "/hooks/htp", consumes = MediaType.ALL_VALUE)
    public ResponseEntity<Void> callback(
            @RequestBody byte[] raw, // raw bytes, before parsing
            @RequestHeader("X-Timestamp") String ts,
            @RequestHeader("X-Nonce") String nonce,
            @RequestHeader("Idempotency-Key") String eventId,
            @RequestHeader("X-Signature") String signature) throws Exception {
        long sec;
        try {
            sec = Long.parseLong(ts);
        } catch (NumberFormatException e) {
            return ResponseEntity.status(401).build();
        }
        if (Math.abs(Instant.now().getEpochSecond() - sec) > 300) return ResponseEntity.status(401).build();

        String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw));
        String sts = String.join("\n", "POST", callbackPath, ts, nonce, eventId, bodyHash);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] expected = mac.doFinal(sts.getBytes(StandardCharsets.UTF_8));
        byte[] given;
        try {
            given = Base64.getDecoder().decode(signature);
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(401).build();
        }
        if (!MessageDigest.isEqual(expected, given)) return ResponseEntity.status(401).build();

        events.enqueue(new String(raw, StandardCharsets.UTF_8)); // process async, idempotent on event_id
        return ResponseEntity.ok().build();
    }
}

测试你的验签代码

把真实收到的回调粘贴到回调验签,可以看到期望的 string_to_sign 和签名。

幂等处理事件

同一事件可能多次到达(重试、手动重发)。event_id(也是 Idempotency-Key 请求头)在重试时保持不变。

js
async function handleEvent(event) {
  // event_id 唯一索引,重复事件直接忽略
  const fresh = await db.htpEvents.insertIfAbsent({ eventId: event.event_id, type: event.event });
  if (!fresh) return;

  const o = event.data;
  switch (event.event) {
    case 'payment.paid':
      // 确认是你期望的那笔订单
      const order = await db.orders.findByNumber(o.out_order_no);
      if (!order || order.htpPaymentId !== o.id || order.total !== o.amount) return alertOps(event);
      await db.orders.markPaid(order.id, { paidAt: o.completed_at, payerSub: o.user_sub, net: o.net_amount });
      break;
    case 'payment.refunded':
      await db.refunds.markSucceeded(o.out_order_no, { paymentId: o.parent_id, amount: o.amount });
      break;
    case 'payout.completed':
      await db.payouts.markSucceeded(o.out_order_no);
      break;
  }
}

同时检查 data.environment 是否符合预期 —— 配错的沙箱回调地址不应该让真实订单发货。

响应与重试

  • 任意 2xx 即表示确认。请在 10 秒内响应,耗时操作放到响应之后。
  • 不会跟随重定向 —— 请让配置的地址直接响应。
  • 其他情况(非 2xx、超时、连接失败)会在 1 分钟、5 分钟、30 分钟、2 小时、6 小时后重试。最后一次失败后投递标记为 FAILED。
  • 控制台回调记录列出每次投递的尝试次数、最后状态和错误,重发可再次发送。

回调不是唯一的事实来源

如果你的服务停机时间超过重试窗口,请对仍在等待支付的订单调用 GET /open/v1/orders/{id} 恢复状态,并每天用账单对账。

轮换回调密钥

控制台中的轮换密钥会返回新密钥,并让旧密钥立即失效。为避免投递失败,请让验签代码同时接受两个密钥:先部署新密钥作为备用,再轮换,最后移除旧密钥。切换期间失败的投递会自动重试。