Payment webhook
Pass webhookUrl when creating a payment order. When the order enters a merchant-actionable final state, DEEPayment sends a plain JSON webhook to that URL, signed with the platform Ed25519 key (RFC 9421). The body is not encrypted.
Payment webhooks only deliver final states. Verify the signature before posting funds, and use the payment query API when callbacks are duplicated, delayed, or conflicting. The SDK's parsePaymentWebhook performs every check below.
Delivered Statuses
SUCCEEDEDfinalThe payment succeeded and a webhook is sent.
FAILEDfinalThe payment failed and a webhook is sent.
EXPIREDfinalThe order expired and a webhook is sent.
CANCELEDfinalThe order was canceled and a webhook is sent.
PENDING and PROCESSING are visible only in query APIs and do not trigger webhooks.
HTTP Headers
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)): over the exact body bytes (RFC 9530).
Webhook-Event-IdEvent id. Always equals eventId in the body.
Signature-InputLabel platform. keyid selects the platform webhook public key shown in the merchant portal. created and expires are Unix seconds, at most 300 seconds apart. nonce is a UUID v4.
Signatureplatform=:base64(Ed25519 signature):
Signature Rule
The signature base uses the same RFC 9421 format as API requests. Covered components are fixed:
"@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"
@pathand@querycome from yourwebhookUrlas received.@queryis?plus the raw query; it is?when the URL has no query string.- Lines are joined with
\nwithout a trailing newline. The last line is theSignature-Inputvalue without theplatform=label. - Verify with
Ed25519.Verify(platformWebhookPublicKey[keyid], signatureBase, signature).
Every delivery attempt is signed fresh: retries carry a new created, expires, nonce, and Signature, while Webhook-Event-Id and the body stay identical. Deduplicate by eventId.
Event Example
{
"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 identifies one notification event. Retries of the same event reuse the same eventId.
Payment webhooks can include the actual payer reported by the channel, using the same fields as the authenticated payment query. These details are never copied from the order creation request. Unavailable fields are omitted; if both are unavailable, the entire payer object is omitted. Keep documentNumber as a string to preserve leading zeros. No document type is returned or inferred.
| Field | Type | Description |
|---|---|---|
payer | object, optional | Actual payer details reported by the channel. |
payer.name | string, optional | Actual payer name. |
payer.documentNumber | string, optional | Actual payer document number. |
The body is fixed when the notification event is first created, including any available payer details. Retries do not add or refresh payer fields. Previously generated notifications are not backfilled; use the authenticated payment query for an order you own to retrieve available details.
Verification Example
The examples below verify a webhook without the SDK: they recompute Content-Digest, match Webhook-Event-Id to the body, check the time window, select the platform public key by keyid, and verify the Ed25519 signature over the reconstructed signature base. Pass the request path, the raw query string, the raw headers, and the exact body bytes.
The key in the examples is the protocol test vector key. The vector at keyid wk_test_1 with the headers shown in the protocol test data must verify successfully; changing one character of the body or Webhook-Event-Id must fail.
- 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
Merchant Processing Order
- Read the raw request
Preserve the exact body bytes, the request path, and the raw query string for verification.
- Verify the signature
Recompute
Content-Digest, check thatWebhook-Event-Idequals bodyeventId, validate the time window and nonce, select the platform public key bykeyid, and verifySignatureover the reconstructed signature base. Reject on any failure without parsing further. - Store idempotently
Use
eventIdas the delivery idempotency key, and apply status updates idempotently perorderNoormerchantOrderNo. - Confirm when needed
When local state conflicts with the event, query the payment order and trust the confirmed final status. Return HTTP 2xx after processing; non-2xx triggers a retry.