Skip to content

Callbacks and verification ​

Once an order reaches "confirmed", we POST the result to your callback URL.

  • If this order passed a notifyUrl at creation, we use that; otherwise we use the default on your merchant profile.
  • If neither exists, no callback record is created and no error is raised — not every merchant wants callbacks, and those merchants can query instead.

Payload ​

http
POST https://your-shop.com/pay/notify
Content-Type: application/json; charset=utf-8
X-Pay-Timestamp: 1760000000
X-Pay-Signature: <lowercase hex signature>
X-Pay-Event: pay.confirmed
User-Agent: cginfo-pay/0.1
json
{
  "merchantOrderNo": "SHOP-2026-0001",
  "orderNo": "PO20261011XXXXXXXXXXXX",
  "merchantNo": "M00000XXXX",
  "status": 3,
  "statusText": "已确认",
  "amount": "100500000",
  "payAmount": "100500123",
  "receivedAmount": "100500123",
  "amountDisplay": "100.500123",
  "chainId": 2,
  "tokenId": 1,
  "payAddress": "TXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "txHash": "0x...on-chain tx hash",
  "subject": "Order SHOP-2026-0001",
  "confirmedAt": "2026-10-11T09:58:41Z"
}

Field notes:

  • Amounts are integer strings in the smallest unit (amount / payAmount / receivedAmount), with amountDisplay for humans. Do not multiply by decimals yourself — floating point will leave you one smallest unit off, and both sides will believe they are right.
  • status is currently always 3 (confirmed), the only state that triggers a callback. It is present so that future notification types need no protocol change.
  • receivedAmount is what actually arrived on-chain and may exceed the expected amount (the buyer overpaid).

Verification ​

signature = HMAC-SHA256(key = apiKey, message = timestamp + body)

timestamp is the X-Pay-Timestamp header value, concatenated as a string with the raw request body.

go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verifyCallback(r *http.Request) ([]byte, bool) {
    raw, _ := io.ReadAll(r.Body)          // raw bytes, before parsing
    ts := r.Header.Get("X-Pay-Timestamp")
    sig := r.Header.Get("X-Pay-Signature")

    mac := hmac.New(sha256.New, []byte(os.Getenv("PAY_API_KEY")))
    mac.Write([]byte(ts))                 // timestamp: string concatenation, not addition
    mac.Write(raw)                        // body: raw bytes, not re-serialised
    want := hex.EncodeToString(mac.Sum(nil))

    return raw, hmac.Equal([]byte(want), []byte(sig))  // constant-time comparison
}

Three common traps:

  • Sign the raw body. Parse into an object and re-serialise, and a changed field order or whitespace breaks it.
  • Compare in constant time. A short-circuiting == can be used to guess the signature bit by bit.
  • The signing key is your API KEY, not a separate secret. Rotate it here too when you rotate the KEY.

Idempotency: keyed on merchantOrderNo ​

Callbacks are retried, so your handler must be idempotent. Use merchantOrderNo to deduplicate:

go
if alreadyProcessed(p.MerchantOrderNo) {
    w.WriteHeader(http.StatusOK)   // already handled; answer 2xx so retries stop
    return
}

A common mistake is "callback arrived, so ship the goods" — the second callback then ships twice. The right shape is: check whether this order has already been handled, then decide whether to act.

Retries ​

  • You return 2xx → delivery succeeded and we stop retrying. The order then moves to its final state, "finished".
  • Any other status, a timeout, or no response → recorded as a failed attempt and retried on a backoff schedule: roughly 5s → 30s → 2m → 10m → 30m → 1h → 2h → 6h.
  • After the maximum attempts the callback is marked abandoned. This is not a silent drop: the record stays queryable and the platform can re-send it manually.
  • A single callback request has a timeout (about 10 seconds). Your handler should return quickly and push slow work (shipping, email) to a queue rather than blocking the request.
  • Retries send the same payload, which is what makes content-based idempotency checks meaningful.

A failed callback does not change the order state

When a callback does not succeed, the order stays at "confirmed" — the money arrived, we simply could not notify you. So "no callback" does not mean "no payment". Verify with Query order.

Why queries are authoritative ​

A callback is a best-effort channel, so it naturally has these properties:

  • Delayed: the backoff schedule means you may receive it later than the confirmation time.
  • Repeated: retries mean the same result can arrive several times.
  • Lost: domain changed, firewall blocked it, unreachable during a deploy — retries are exhausted and given up.

The right approach is therefore two-track:

  1. Use callbacks as change triggers and handle them promptly.
  2. Before any critical action (shipping, settlement, reconciliation), call Query order for the definitive state.

Requirements for the callback URL ​

  • It must be an http(s) address reachable from the platform — not a private network address.
  • The platform makes requests to it, so secure it as an internet-facing service, not as an internal endpoint.
  • Prefer persist-then-act handling for a given order: record "callback received" first, then perform the side effects, so a crash halfway through does not leave the state ambiguous.

Web3 payment infrastructure for global merchants