Skip to main content

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​

SUCCEEDEDfinal

The payment succeeded and a webhook is sent.

FAILEDfinal

The payment failed and a webhook is sent.

EXPIREDfinal

The order expired and a webhook is sent.

CANCELEDfinal

The 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-Digest

sha-256=:base64(SHA-256(rawBody)): over the exact body bytes (RFC 9530).

Webhook-Event-Id

Event id. Always equals eventId in the body.

Signature-Input

Label 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.

Signature

platform=: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"
  • @path and @query come from your webhookUrl as received. @query is ? plus the raw query; it is ? when the URL has no query string.
  • Lines are joined with \n without a trailing newline. The last line is the Signature-Input value without the platform= 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.

FieldTypeDescription
payerobject, optionalActual payer details reported by the channel.
payer.namestring, optionalActual payer name.
payer.documentNumberstring, optionalActual 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.

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
}

Merchant Processing Order​

  1. Read the raw request

    Preserve the exact body bytes, the request path, and the raw query string for verification.

  2. Verify the signature

    Recompute Content-Digest, check that Webhook-Event-Id equals body eventId, validate the time window and nonce, select the platform public key by keyid, and verify Signature over the reconstructed signature base. Reject on any failure without parsing further.

  3. Store idempotently

    Use eventId as the delivery idempotency key, and apply status updates idempotently per orderNo or merchantOrderNo.

  4. 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.