主题
03 · 回调通知(Webhook)
充值到账与提现完成由平台主动推送到商户登记的回调地址。回调是商户入账的权威数据源。
1. 推送方式
| 项 | 值 |
|---|---|
| 方法 | POST |
| 地址 | 商户在控制台登记的 callback_url(必须公网可达,禁止内网地址) |
| Content-Type | application/json |
| 扫描周期 | 每 5 秒扫描一次待推送队列 |
回调地址会做 SSRF 校验:必须是 http/https,且解析出的 IP 不能落在私有/回环网段。
2. 报文格式
json
{
"verify_data": {
"project_uid": "Ab3xY9Kq7Z",
"type": "deposit",
"uid": "0x60a7904f2299519a2952c53a330a03cede4c55e9158e89fc132463130965bcaf",
"tx_hash": "0x60a7904f2299519a2952c53a330a03cede4c55e9158e89fc132463130965bcaf",
"from_address": "0x93c18812f4955b713304abc3f90652d8b93cddde",
"to_address": "0xf9c66b4c44943345881ade7bad4e096f6550b7e9",
"address_uid": "Ab3xY9Kq7Z-100286",
"amount": "100500000",
"float_amount": "100.5000",
"decimal": 6,
"asset_id": 2,
"asset_name": "usdt",
"network_id": 1,
"network_name": "Ethereum Mainnet",
"status": "confirmed",
"confirmed": true,
"confirmed_at": "2026-08-10T03:00:40.086062Z"
},
"sig": "3ce7a1b0...9f4e1c00"
}verify_data 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
project_uid | string | 项目标识 |
type | string | deposit(充值)或 withdraw(提现) |
uid | string | 业务唯一键。充值时 = tx_hash;提现时 = 商户发起提现时传的 uid |
tx_hash | string | 链上交易哈希。提现在交易未广播前可能为空 |
from_address | string | 转出地址 |
to_address | string | 转入地址(充值时是用户充值地址,提现时是收款地址) |
address_uid | string | 充值地址绑定的用户 uid(提现回调为空字符串) |
amount | string | 金额,最小单位(整数字符串) |
float_amount | string | 金额,可读单位(如 "100.5000")。入账建议直接用这个 |
decimal | number | 该币种在该网络的精度 |
asset_id / asset_name | number / string | 币种,如 2 / usdt |
network_id / network_name | number / string | 网络,如 1 / Ethereum Mainnet |
status | string | 充值:pending / confirmed / timeout;提现:success / confirmed |
confirmed | bool | 是否已达到确认数 |
confirmed_at | string | null | 确认时间(RFC3339),未确认为 null |
注意报文的 JSON 是缩进格式(pretty-printed),不是紧凑格式。 验签时不要直接对原始报文体做哈希,见第 4 节。
3. 应答规范(最重要)
平台根据你返回的 HTTP 状态码决定后续行为:
| 你返回 | 平台行为 |
|---|---|
201 | 判定为处理完成,停止推送该笔 |
200 | 判定为尚未完成,按退避策略继续推送,直到收到 201 或达到最大次数 |
4xx | 判定为永久失败,立即停止推送(不再重试) |
5xx / 超时 / 连接失败 | 判定为临时失败,按退避策略重试 |
这套语义让商户可以「等到确认再确认收货」:
充值回调第 1 次:status=pending, confirmed=false → 商户返回 200(还不入账)
充值回调第 2 次:status=pending, confirmed=false → 商户返回 200
充值回调第 N 次:status=confirmed, confirmed=true → 商户入账后返回 201(停止推送)推荐的处理逻辑
1. 解析 verify_data + sig
2. 用平台公钥验签 → 失败则返回 401(4xx,平台停止推送并告警)
3. 幂等检查:该 (type, uid) 是否已处理 → 已处理直接返回 201
4. if type == "deposit":
if status == "confirmed" && confirmed == true:
入账(同一事务内写入流水 + 余额)
return 201
else:
return 200 // 让平台继续推,直到确认
if type == "withdraw":
if status == "confirmed":
标记提现完成
return 201
else: // status == "success",已广播未最终确认
return 200
5. 处理过程中出现内部异常 → 返回 5xx,让平台重试切勿在
confirmed=false时就给用户加余额——链重组会导致这笔充值消失。
4. 验签
sig 是平台用平台私钥对 verify_data 的签名。验证步骤:
- 取出
verify_data对象(不是整个报文,也不是原始字节)。 - 对其做递归键名排序后序列化为紧凑 JSON(无空格、无缩进)。
sha256得到 32 字节摘要。- 用平台公钥做 secp256k1 验签(签名为 65 字节
r||s||v,验签时取前 64 字节)。
Go(使用 SDK,一行搞定):
go
ok, err := client.VerifyPlatformSignature(client.GetPlatformPublicKey(), callbackReq.VerifyData, callbackReq.Sig)其它语言的实现见 04-签名规范.md 第 3 节「验证平台签名」。
sig为十六进制、无0x前缀(验签实现应兼容带前缀的情况)。
5. 重试策略
| 项 | 值 |
|---|---|
| 退避公式 | 10s × 2^retries,即 10s、20s、40s、80s、160s、… |
| 最大间隔 | 5 分钟(超过后固定为 5 分钟) |
| 最大重试次数 | 50 次 |
| 总时长 | 约 4 小时 |
超过 50 次后该笔回调标记为 failed,平台不再自动推送。此时可以联系平台在控制台手动补推。
6. 触发时机
充值
- 索引器在链上扫描到入账交易、写入充值记录后立即开始推送(此时
status=pending,confirmed=false)。 - 达到确认数(Ethereum / BSC / TRON 均为 6 个区块)后,后续推送的
status变为confirmed。 - 归集产生的内部转账(充值地址 → 归集地址)不会推送给商户。
提现
- 只有
withdraw_status变为success或confirmed时才会推送。 - 归集到冷钱包产生的内部提现单不会推送。
- ⚠️ 被拒绝(
rejected)、失败(failed/error)的提现单不会产生回调。 商户需要通过控制台或人工对账处理这类单据;如需失败通知请联系平台评估。
7. 接收端示例(Go)
go
package main
import (
"encoding/json"
"io"
"log"
"net/http"
linkepay "github.com/linkepay/linkepay-sdk-go"
"github.com/linkepay/linkepay-sdk-go/types"
)
var client *linkepay.Client
func init() {
client = linkepay.NewClient(&types.Config{
BaseURL: "https://linkepay-api.bestxx.com",
ProjectID: "<PROJECT_UID>",
PrivateKey: "<MERCHANT_PRIVATE_KEY>",
PublicKey: "<MERCHANT_PUBLIC_KEY>",
ApiKey: "<API_KEY>",
PayPlatformPublicKey: "0xc0b2388188f35c087400575393c124e9c459550f1dfcf848214f47637b599ee08a6b64876c4687e869d070d892b97071fd4c90b0a3491814823b4e97f8e7d50f",
})
}
type callbackReq struct {
VerifyData map[string]interface{} `json:"verify_data"`
Sig string `json:"sig"`
}
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // 5xx → 平台重试
return
}
defer r.Body.Close()
var req callbackReq
if err := json.Unmarshal(body, &req); err != nil {
w.WriteHeader(http.StatusBadRequest) // 4xx → 平台停止推送
return
}
// 1. 验签
ok, err := client.VerifyPlatformSignature(client.GetPlatformPublicKey(), req.VerifyData, req.Sig)
if err != nil || !ok {
log.Printf("invalid callback signature: %v", err)
w.WriteHeader(http.StatusUnauthorized) // 4xx,同时应触发告警
return
}
typ, _ := req.VerifyData["type"].(string)
uid, _ := req.VerifyData["uid"].(string)
status, _ := req.VerifyData["status"].(string)
confirmed, _ := req.VerifyData["confirmed"].(bool)
// 2. 幂等
if alreadyProcessed(typ, uid) {
w.WriteHeader(http.StatusCreated) // 201,停止推送
return
}
// 3. 只在确认后入账
if typ == "deposit" && status == "confirmed" && confirmed {
if err := creditUser(req.VerifyData); err != nil {
log.Printf("credit failed: %v", err)
w.WriteHeader(http.StatusInternalServerError) // 5xx → 平台重试
return
}
w.WriteHeader(http.StatusCreated)
return
}
if typ == "withdraw" && status == "confirmed" {
if err := finishWithdraw(uid, req.VerifyData); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
return
}
// 还没到终态:返回 200,让平台继续推送
w.WriteHeader(http.StatusOK)
}
func alreadyProcessed(typ, uid string) bool { /* 查库去重 */ return false }
func creditUser(d map[string]interface{}) error { /* 事务内加余额 + 写流水 */ return nil }
func finishWithdraw(uid string, d map[string]interface{}) error { return nil }
func main() {
http.HandleFunc("/linkepay/callback", handler)
log.Fatal(http.ListenAndServe(":8081", nil))
}8. 排查清单
| 现象 | 排查方向 |
|---|---|
| 收不到任何回调 | 控制台的 callback_url 是否填写、callback_enabled 是否为 true;地址是否公网可达;是否被 SSRF 校验拦截(内网地址) |
| 回调一直重复推送 | 是否返回了 200 而不是 201;或返回体导致平台判定为未完成 |
| 回调突然不推了 | 是否某次返回了 4xx(永久失败);或已达 50 次上限 |
| 验签失败 | 是否对 verify_data 而不是整个报文验签;是否做了递归键排序;是否用了正确的平台公钥 |
| 收到重复入账 | 商户侧幂等键是否用了 (type, uid);充值的 uid 就是 tx_hash |