Callbacks and verification
Once an order reaches "confirmed", we POST the result to your callback URL.
- If this order passed a
notifyUrlat 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
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{
"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), withamountDisplayfor humans. Do not multiply bydecimalsyourself — floating point will leave you one smallest unit off, and both sides will believe they are right. statusis currently always 3 (confirmed), the only state that triggers a callback. It is present so that future notification types need no protocol change.receivedAmountis 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.
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:
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:
- Use callbacks as change triggers and handle them promptly.
- 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.
Related
- Query order
- Auth and signatures — the algorithm and key rotation
- Errors