跳到主要内容

代收 webhook

创建代收订单时传入 webhookUrl。订单进入商户可处理的最终状态后,DEEPayment 会向该地址发送明文 JSON webhook,并使用平台 Ed25519 私钥签名(RFC 9421)。body 不加密。

代收 webhook 只投递最终状态。商户必须先验签再入账,回调重复、延迟或状态冲突时,用查询代收订单接口确认最终状态。SDK 的 parsePaymentWebhook 会完成下面全部校验。

投递状态​

SUCCEEDED终态

代收成功,会投递 webhook。

FAILED终态

代收失败,会投递 webhook。

EXPIRED终态

订单过期,会投递 webhook。

CANCELED终态

订单取消,会投递 webhook。

PENDING、PROCESSING 只在查询接口可见,不触发 webhook。内部异常处理状态不会投递给商户;订单恢复到最终状态后再投递最终结果。

HTTP Header​

POST /webhook/payment HTTP/1.1
Content-Type: application/json
Content-Digest: sha-256=:1bxiy7QF1KLU6+Jk7A57caRaycHHhfNvu8iYeMUm7ow=:
Webhook-Event-Id: evt_0123456789ABCDEFGHJKMNPQRS
Signature-Input: platform=("@method" "@path" "@query" "content-type" "content-digest" "webhook-event-id");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";keyid="pwhk_20260827_01";alg="ed25519"
Signature: platform=:base64-ed25519-signature:
Content-Digest

sha-256=:base64(SHA-256(rawBody)):,对实际 body 字节计算(RFC 9530)。

Webhook-Event-Id

事件 id,恒等于 body 中的 eventId。

Signature-Input

label 固定 platform。keyid 对应商户后台展示的平台 webhook 公钥。created、expires 为 Unix 秒,间隔不超过 300 秒。nonce 是 UUID v4。

Signature

platform=:base64(Ed25519 签名):。

签名规则​

签名基串格式与商户请求签名相同(RFC 9421),覆盖字段固定:

"@method": POST
"@path": /webhook/payment
"@query": ?
"content-type": application/json
"content-digest": sha-256=:1bxiy7QF1KLU6+Jk7A57caRaycHHhfNvu8iYeMUm7ow=:
"webhook-event-id": evt_0123456789ABCDEFGHJKMNPQRS
"@signature-params": ("@method" "@path" "@query" "content-type" "content-digest" "webhook-event-id");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";keyid="pwhk_20260827_01";alg="ed25519"
  • @path、@query 取实际收到的 webhookUrl。@query 是 ? 加原始 query;URL 没有 query 时为 ?。
  • 各行用 \n 拼接,末尾不追加换行。最后一行是去掉 platform= label 的 Signature-Input 值。
  • 校验:Ed25519.Verify(platformWebhookPublicKey[keyid], signatureBase, signature)。

每次投递都重新签名:重试时 created、expires、nonce、Signature 都会变化,Webhook-Event-Id 和 body 保持不变。商户按 eventId 去重。

事件示例​

{
"eventId": "evt_0123456789ABCDEFGHJKMNPQRS",
"orderType": "PAYMENT",
"orderNo": "FP202605060001",
"merchantOrderNo": "M202605060001",
"status": "SUCCEEDED",
"currency": "BRL",
"amount": "100.00",
"paidAmount": "100.00",
"channelTradeNo": "E1234567890",
"payer": {
"name": "Maria Silva",
"documentNumber": "01234567890"
},
"attach": "user_123"
}

字段说明​

eventId

通知事件 id,同一事件的重试投递复用同一个值;与 Header Webhook-Event-Id 相同。

orderType

固定为 PAYMENT。

orderNo

DEEPayment 代收订单号。

merchantOrderNo

商户代收订单号。

status

代收最终状态。

currency

订单币种。

amount

订单金额。

paidAmount

实际成功金额。

channelTradeNo

通道、清算网络或支付网络返回的交易号;BRL PIX 映射 End-to-end ID。

payer

可选,仅代收事件返回渠道提供的实际付款人;没有姓名和证件号时省略。

payer.name

可选,实际付款人姓名;渠道未提供时省略。

payer.documentNumber

可选,实际付款人证件号,保持字符串及前导零;渠道未提供时省略。

attach

商户创建订单时传入的附加信息。

代收 Webhook 可返回渠道提供的实际付款人,与签名鉴权的代收查单使用相同字段,不使用商户下单资料回填。渠道未提供的子字段省略,两个字段均缺失时省略整个 payer。documentNumber 保持字符串及前导零,不返回或推断证件类型。

通知事件首次生成时固定正文,包括当时已有的付款人信息;重试不会补充或刷新字段。历史已生成通知不会自动补上 payer,可通过签名鉴权的代收查单获取本商户订单已有的付款人信息。

验签示例​

下面的示例不依赖 SDK 完成 webhook 验签:重算 Content-Digest,比对 Webhook-Event-Id 与 body,校验时间窗口,按 keyid 选择平台公钥,对重建的签名基串校验 Ed25519 签名。传入请求 path、原始 query、原始 header 和原始 body 字节。

示例中的公钥是协议测试向量密钥。用协议测试数据中的 header 和 body 调用,keyid 为 wk_test_1 的向量必须验签通过;改动 body 或 Webhook-Event-Id 任一字符必须失败。

package main

import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)

// Platform webhook public keys from the merchant portal, keyed by keyid.
var platformWebhookPublicKeys = map[string]string{
"wk_test_1": "oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA=",
}

var platformCovered = []string{"@method", "@path", "@query", "content-type", "content-digest", "webhook-event-id"}
var sigInputRe = regexp.MustCompile(`^platform=\(.*\);created=(\d+);expires=(\d+);nonce="[^"]+";keyid="([^"]+)";alg="ed25519"$`)

// VerifyWebhook checks digest, event id, freshness and the platform Ed25519 signature.
// path and rawQuery come from the request URL as received; headers are the raw header values.
func VerifyWebhook(path, rawQuery string, headers map[string]string, body []byte, now time.Time) (map[string]any, error) {
get := func(name string) string { return strings.TrimSpace(headers[name]) }
signatureInput := get("Signature-Input")

sum := sha256.Sum256(body)
if get("Content-Digest") != "sha-256=:"+base64.StdEncoding.EncodeToString(sum[:])+":" {
return nil, errors.New("content digest mismatch")
}
var event map[string]any
if err := json.Unmarshal(body, &event); err != nil {
return nil, err
}
if id, _ := event["eventId"].(string); id == "" || id != get("Webhook-Event-Id") {
return nil, errors.New("event id mismatch")
}

m := sigInputRe.FindStringSubmatch(signatureInput)
if m == nil {
return nil, errors.New("bad Signature-Input")
}
created, _ := strconv.ParseInt(m[1], 10, 64)
expires, _ := strconv.ParseInt(m[2], 10, 64)
if expires-created > 300 || now.Unix() > expires || now.Unix() < created-300 {
return nil, errors.New("signature expired")
}
pubB64, ok := platformWebhookPublicKeys[m[3]]
if !ok {
return nil, errors.New("unknown keyid")
}
pub, _ := base64.StdEncoding.DecodeString(pubB64)

derived := map[string]string{"@method": "POST", "@path": path, "@query": "?" + rawQuery}
lines := make([]string, 0, len(platformCovered)+1)
for _, c := range platformCovered {
value, ok := derived[c]
if !ok {
value = get(http.CanonicalHeaderKey(c))
}
lines = append(lines, strconv.Quote(c)+": "+value)
}
lines = append(lines, `"@signature-params": `+strings.TrimPrefix(signatureInput, "platform="))
base := []byte(strings.Join(lines, "\n"))

sigHeader := get("Signature")
if !strings.HasPrefix(sigHeader, "platform=:") || !strings.HasSuffix(sigHeader, ":") {
return nil, errors.New("bad Signature")
}
sig, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(sigHeader, "platform=:"), ":"))
if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), base, sig) {
return nil, errors.New("signature invalid")
}
return event, nil
}

商户处理顺序​

  1. 读取原始请求

    保留 HTTP raw body 原始字节、请求 path 和原始 query 用于验签。

  2. 校验签名

    重算 Content-Digest,确认 Webhook-Event-Id 等于 body eventId,校验时间窗口和 nonce,按 keyid 选择平台公钥,对重建的签名基串校验 Signature。任一步失败直接拒绝,不解析业务字段。

  3. 幂等落库

    以 eventId 做投递去重,按 orderNo 或 merchantOrderNo 对订单状态更新做业务幂等,入账以终态为准。

  4. 返回 2xx

    商户响应任意 HTTP 2xx 视为成功,body 内容不作要求。非 2xx 会触发重试。

状态冲突处理​

如果本地状态与 webhook 状态冲突,或者同一订单收到不同终态事件,先冻结本地自动处理并调用查询代收订单接口确认。对账、发货等动作应以已验签 webhook 和查询接口共同确认后的终态为准。