代收 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-Digestsha-256=:base64(SHA-256(rawBody)):,对实际 body 字节计算(RFC 9530)。
Webhook-Event-Id事件 id,恒等于 body 中的 eventId。
Signature-Inputlabel 固定 platform。keyid 对应商户后台展示的平台 webhook 公钥。created、expires 为 Unix 秒,间隔不超过 300 秒。nonce 是 UUID v4。
Signatureplatform=: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。
orderNoDEEPayment 代收订单号。
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 任一字符必须失败。
- Go
- JavaScript
- Python
- PHP
- Java
- Ruby
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
}
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
const sodium = createRequire(import.meta.url)('libsodium-wrappers');
// Platform webhook public keys from the merchant portal, keyed by keyid.
const platformWebhookPublicKeys = { wk_test_1: 'oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA=' };
const PLATFORM_COVERED = ['@method', '@path', '@query', 'content-type', 'content-digest', 'webhook-event-id'];
const SIG_INPUT = /^platform=\(.*\);created=(\d+);expires=(\d+);nonce="[^"]+";keyid="([^"]+)";alg="ed25519"$/;
// Verifies digest, event id, freshness and the platform Ed25519 signature; returns the parsed event.
// path and rawQuery come from the request URL as received; body is the exact bytes received.
export async function verifyWebhook({ path, rawQuery = '', headers, body, now = Math.floor(Date.now() / 1000) }) {
await sodium.ready;
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v).trim()]));
const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
if (h['content-digest'] !== digest) throw new Error('content digest mismatch');
const event = JSON.parse(Buffer.from(body).toString('utf8'));
if (!event.eventId || event.eventId !== h['webhook-event-id']) throw new Error('event id mismatch');
const m = SIG_INPUT.exec(h['signature-input']);
if (!m) throw new Error('bad Signature-Input');
const created = Number(m[1]); const expires = Number(m[2]);
if (expires - created > 300 || now > expires || now < created - 300) throw new Error('signature expired');
const pub = platformWebhookPublicKeys[m[3]];
if (!pub) throw new Error('unknown keyid');
const derived = { '@method': 'POST', '@path': path, '@query': `?${rawQuery}` };
const lines = PLATFORM_COVERED.map((c) => `${JSON.stringify(c)}: ${derived[c] ?? h[c]}`);
lines.push(`"@signature-params": ${h['signature-input'].slice('platform='.length)}`);
const base = Buffer.from(lines.join('\n'), 'utf8');
const sigMatch = /^platform=:(.+):$/.exec(h['signature']);
if (!sigMatch) throw new Error('bad Signature');
const ok = sodium.crypto_sign_verify_detached(Buffer.from(sigMatch[1], 'base64'), base, Buffer.from(pub, 'base64'));
if (!ok) throw new Error('signature invalid');
return event;
}
import base64
import hashlib
import json
import re
import time
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
# Platform webhook public keys from the merchant portal, keyed by keyid.
PLATFORM_WEBHOOK_PUBLIC_KEYS = {"wk_test_1": "oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA="}
PLATFORM_COVERED = ("@method", "@path", "@query", "content-type", "content-digest", "webhook-event-id")
SIG_INPUT = re.compile(r'^platform=\(.*\);created=(\d+);expires=(\d+);nonce="[^"]+";keyid="([^"]+)";alg="ed25519"$')
SIG_VALUE = re.compile(r"^platform=:(.+):$")
def verify_webhook(path: str, raw_query: str, headers: dict, body: bytes, now: int | None = None) -> dict:
"""Verify digest, event id, freshness and the platform Ed25519 signature; return the parsed event.
path and raw_query come from the request URL as received; body is the exact bytes received.
"""
now = now or int(time.time())
h = {k.lower(): str(v).strip() for k, v in headers.items()}
digest = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
if h.get("content-digest") != digest:
raise ValueError("content digest mismatch")
event = json.loads(body)
if not event.get("eventId") or event["eventId"] != h.get("webhook-event-id"):
raise ValueError("event id mismatch")
m = SIG_INPUT.match(h.get("signature-input", ""))
if not m:
raise ValueError("bad Signature-Input")
created, expires = int(m.group(1)), int(m.group(2))
if expires - created > 300 or now > expires or now < created - 300:
raise ValueError("signature expired")
pub = PLATFORM_WEBHOOK_PUBLIC_KEYS.get(m.group(3))
if not pub:
raise ValueError("unknown keyid")
derived = {"@method": "POST", "@path": path, "@query": "?" + raw_query}
lines = [f"{json.dumps(c)}: {derived.get(c, h.get(c))}" for c in PLATFORM_COVERED]
lines.append('"@signature-params": ' + h["signature-input"][len("platform="):])
base = "\n".join(lines).encode()
sig_match = SIG_VALUE.match(h.get("signature", ""))
if not sig_match:
raise ValueError("bad Signature")
try:
VerifyKey(base64.b64decode(pub)).verify(base, base64.b64decode(sig_match.group(1)))
except BadSignatureError as exc:
raise ValueError("signature invalid") from exc
return event
<?php
// Platform webhook public keys from the merchant portal, keyed by keyid.
const PLATFORM_WEBHOOK_PUBLIC_KEYS = ['wk_test_1' => 'oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA='];
const PLATFORM_COVERED = ['@method', '@path', '@query', 'content-type', 'content-digest', 'webhook-event-id'];
// Verifies digest, event id, freshness and the platform Ed25519 signature; returns the parsed event.
// $path and $rawQuery come from the request URL as received; $body is the exact bytes received.
function verifyWebhook(string $path, string $rawQuery, array $headers, string $body, ?int $now = null): array
{
$now ??= time();
$h = [];
foreach ($headers as $k => $v) {
$h[strtolower($k)] = trim((string) $v);
}
if (($h['content-digest'] ?? '') !== 'sha-256=:' . base64_encode(hash('sha256', $body, true)) . ':') {
throw new RuntimeException('content digest mismatch');
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if (empty($event['eventId']) || $event['eventId'] !== ($h['webhook-event-id'] ?? '')) {
throw new RuntimeException('event id mismatch');
}
if (!preg_match('/^platform=\(.*\);created=(\d+);expires=(\d+);nonce="[^"]+";keyid="([^"]+)";alg="ed25519"$/', $h['signature-input'] ?? '', $m)) {
throw new RuntimeException('bad Signature-Input');
}
[$created, $expires] = [(int) $m[1], (int) $m[2]];
if ($expires - $created > 300 || $now > $expires || $now < $created - 300) {
throw new RuntimeException('signature expired');
}
$pub = PLATFORM_WEBHOOK_PUBLIC_KEYS[$m[3]] ?? null;
if ($pub === null) {
throw new RuntimeException('unknown keyid');
}
$derived = ['@method' => 'POST', '@path' => $path, '@query' => '?' . $rawQuery];
$lines = array_map(fn($c) => json_encode($c) . ': ' . ($derived[$c] ?? $h[$c]), PLATFORM_COVERED);
$lines[] = '"@signature-params": ' . substr($h['signature-input'], strlen('platform='));
$base = implode("\n", $lines);
if (!preg_match('/^platform=:(.+):$/', $h['signature'] ?? '', $s)) {
throw new RuntimeException('bad Signature');
}
if (!sodium_crypto_sign_verify_detached(base64_decode($s[1], true), $base, base64_decode($pub, true))) {
throw new RuntimeException('signature invalid');
}
return $event;
}
// Usage inside your webhook endpoint
$event = verifyWebhook(
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
$_SERVER['QUERY_STRING'] ?? '',
getallheaders(),
file_get_contents('php://input')
);
// deduplicate by $event['eventId'], apply $event['status'], then respond 200
import com.goterl.lazysodium.LazySodiumJava;
import com.goterl.lazysodium.SodiumJava;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class WebhookVerifier {
// Platform webhook public keys from the merchant portal, keyed by keyid.
static final Map<String, String> PLATFORM_WEBHOOK_PUBLIC_KEYS = Map.of("wk_test_1", "oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA=");
static final List<String> PLATFORM_COVERED = List.of("@method", "@path", "@query", "content-type", "content-digest", "webhook-event-id");
static final Pattern SIG_INPUT = Pattern.compile("^platform=\\(.*\\);created=(\\d+);expires=(\\d+);nonce=\"[^\"]+\";keyid=\"([^\"]+)\";alg=\"ed25519\"$");
static final Pattern SIG_VALUE = Pattern.compile("^platform=:(.+):$");
static final LazySodiumJava SODIUM = new LazySodiumJava(new SodiumJava());
/**
* Verifies digest, event id, freshness and the platform Ed25519 signature; returns the raw JSON body.
* path and rawQuery come from the request URL as received; body is the exact bytes received.
* Parse the returned JSON with your JSON library and deduplicate by eventId.
*/
static String verifyWebhook(String path, String rawQuery, Map<String, String> headers, byte[] body) throws Exception {
Map<String, String> h = new HashMap<>();
headers.forEach((k, v) -> h.put(k.toLowerCase(Locale.ROOT), v.trim()));
long now = System.currentTimeMillis() / 1000;
String digest = "sha-256=:" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body)) + ":";
if (!digest.equals(h.get("content-digest"))) throw new SecurityException("content digest mismatch");
String json = new String(body, StandardCharsets.UTF_8);
String eventId = h.get("webhook-event-id");
if (eventId == null || !json.contains("\"eventId\":\"" + eventId + "\"")) throw new SecurityException("event id mismatch");
Matcher m = SIG_INPUT.matcher(h.getOrDefault("signature-input", ""));
if (!m.matches()) throw new SecurityException("bad Signature-Input");
long created = Long.parseLong(m.group(1)), expires = Long.parseLong(m.group(2));
if (expires - created > 300 || now > expires || now < created - 300) throw new SecurityException("signature expired");
String pub = PLATFORM_WEBHOOK_PUBLIC_KEYS.get(m.group(3));
if (pub == null) throw new SecurityException("unknown keyid");
Map<String, String> derived = Map.of("@method", "POST", "@path", path, "@query", "?" + rawQuery);
StringJoiner lines = new StringJoiner("\n");
PLATFORM_COVERED.forEach(c -> lines.add("\"" + c + "\": " + derived.getOrDefault(c, h.get(c))));
lines.add("\"@signature-params\": " + h.get("signature-input").substring("platform=".length()));
byte[] base = lines.toString().getBytes(StandardCharsets.UTF_8);
Matcher s = SIG_VALUE.matcher(h.getOrDefault("signature", ""));
if (!s.matches()) throw new SecurityException("bad Signature");
byte[] sig = Base64.getDecoder().decode(s.group(1));
if (!SODIUM.cryptoSignVerifyDetached(sig, base, base.length, Base64.getDecoder().decode(pub))) {
throw new SecurityException("signature invalid");
}
return json;
}
}
require 'rbnacl'
require 'base64'
require 'digest'
require 'json'
# Platform webhook public keys from the merchant portal, keyed by keyid.
PLATFORM_WEBHOOK_PUBLIC_KEYS = { 'wk_test_1' => 'oJql9HpnWYAv+VX43C0qFKXJnSO+l/hkEn/5ODRVpPA=' }.freeze
PLATFORM_COVERED = %w[@method @path @query content-type content-digest webhook-event-id].freeze
SIG_INPUT = /\Aplatform=\(.*\);created=(\d+);expires=(\d+);nonce="[^"]+";keyid="([^"]+)";alg="ed25519"\z/
# Verifies digest, event id, freshness and the platform Ed25519 signature; returns the parsed event.
# path and raw_query come from the request URL as received; body is the exact bytes received.
def verify_webhook(path, raw_query, headers, body, now: Time.now.to_i)
h = headers.to_h { |k, v| [k.to_s.downcase, v.to_s.strip] }
digest = "sha-256=:#{Base64.strict_encode64(Digest::SHA256.digest(body))}:"
raise 'content digest mismatch' unless h['content-digest'] == digest
event = JSON.parse(body)
raise 'event id mismatch' if event['eventId'].to_s.empty? || event['eventId'] != h['webhook-event-id']
m = SIG_INPUT.match(h['signature-input'].to_s) or raise 'bad Signature-Input'
created, expires = m[1].to_i, m[2].to_i
raise 'signature expired' if expires - created > 300 || now > expires || now < created - 300
pub = PLATFORM_WEBHOOK_PUBLIC_KEYS[m[3]] or raise 'unknown keyid'
derived = { '@method' => 'POST', '@path' => path, '@query' => "?#{raw_query}" }
lines = PLATFORM_COVERED.map { |c| "#{c.to_json}: #{derived.fetch(c) { h[c] }}" }
lines << "\"@signature-params\": #{h['signature-input'].delete_prefix('platform=')}"
base = lines.join("\n")
s = /\Aplatform=:(.+):\z/.match(h['signature'].to_s) or raise 'bad Signature'
verify_key = RbNaCl::VerifyKey.new(Base64.strict_decode64(pub))
begin
verify_key.verify(Base64.strict_decode64(s[1]), base)
rescue RbNaCl::BadSignatureError
raise 'signature invalid'
end
event
end
# Usage inside a Rack/Rails webhook endpoint
# event = verify_webhook(request.path, request.query_string, request.headers, request.raw_post)
# deduplicate by event['eventId'], apply event['status'], then respond 200
商户处理顺序
- 读取原始请求
保留 HTTP raw body 原始字节、请求 path 和原始 query 用于验签。
- 校验签名
重算
Content-Digest,确认Webhook-Event-Id等于 bodyeventId,校验时间窗口和 nonce,按keyid选择平台公钥,对重建的签名基串校验Signature。任一步失败直接拒绝,不解析业务字段。 - 幂等落库
以
eventId做投递去重,按orderNo或merchantOrderNo对订单状态更新做业务幂等,入账以终态为准。 - 返回 2xx
商户响应任意 HTTP 2xx 视为成功,body 内容不作要求。非 2xx 会触发重试。
状态冲突处理
如果本地状态与 webhook 状态冲突,或者同一订单收到不同终态事件,先冻结本地自动处理并调用查询代收订单接口确认。对账、发货等动作应以已验签 webhook 和查询接口共同确认后的终态为准。