收付款
回调
接收并校验签名的 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 "", 200Go
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} 恢复状态,并每天用账单对账。
轮换回调密钥
控制台中的轮换密钥会返回新密钥,并让旧密钥立即失效。为避免投递失败,请让验签代码同时接受两个密钥:先部署新密钥作为备用,再轮换,最后移除旧密钥。切换期间失败的投递会自动重试。