---
url: /guide/examples.md
---
# 多语言示例

每种语言一个文件：**请求签名 + 发请求 + 回调验签**，只依赖标准库（Python 用 `requests`）。可以直接拷进项目使用。

所有实现都已用 [签名认证](./authentication) 与 [回调](./webhooks) 页的测试数据验证过。

## 客户端

::: code-group

```go \[Go]
// DTPG Wallet merchant API — Go example: request signing + webhook verification (standard library only).
package dtpgwallet

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"
)

type Client struct {
	BaseURL string // https://api.dtpg.net
	APIKey  string
	Secret  string
	HTTP    *http.Client
}

// APIError is the unified error body. Branch on Reason; Message is for developers only.
type APIError struct {
	Code     int               `json:"code"`
	Reason   string            `json:"reason"`
	Message  string            `json:"message"`
	Metadata map[string]string `json:"metadata"`
}

func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Code, e.Reason, e.Message) }

// CanonicalQuery sorts by key (then by value for repeated keys), RFC 3986-encodes (space = %20) and joins with &.
func CanonicalQuery(q url.Values) string {
	keys := make([]string, 0, len(q))
	for k := range q {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var parts []string
	for _, k := range keys {
		vs := append([]string(nil), q[k]...)
		sort.Strings(vs)
		for _, v := range vs {
			parts = append(parts, rfc3986(k)+"="+rfc3986(v))
		}
	}
	return strings.Join(parts, "&")
}

func rfc3986(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") }

func hmacHex(secret, msg string) string {
	m := hmac.New(sha256.New, []byte(secret))
	m.Write([]byte(msg))
	return hex.EncodeToString(m.Sum(nil))
}

// Sign returns X-Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nhex(sha256(BODY)))).
func Sign(secret, method, path, canonicalQuery, timestamp, nonce string, body []byte) string {
	h := sha256.Sum256(body)
	return hmacHex(secret, strings.Join([]string{method, path, canonicalQuery, timestamp, nonce, hex.EncodeToString(h[:])}, "\n"))
}

// Do sends a signed request. body == nil means no request body; out receives the response entity.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
	var raw []byte
	if body != nil {
		var err error
		if raw, err = json.Marshal(body); err != nil {
			return err
		}
	}
	// Send the canonical query string itself, so what is signed is exactly what is sent.
	q := CanonicalQuery(query)
	u := c.BaseURL + path
	if q != "" {
		u += "?" + q
	}
	ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
	nb := make([]byte, 16)
	if _, err := rand.Read(nb); err != nil {
		return err
	}
	nonce := hex.EncodeToString(nb)

	req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(raw))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Api-Key", c.APIKey)
	req.Header.Set("X-Timestamp", ts)
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", Sign(c.Secret, method, req.URL.EscapedPath(), q, ts, nonce, raw))

	hc := c.HTTP
	if hc == nil {
		hc = &http.Client{Timeout: 15 * time.Second}
	}
	resp, err := hc.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	if resp.StatusCode != http.StatusOK {
		e := &APIError{Code: resp.StatusCode}
		_ = json.Unmarshal(data, e)
		return e
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(data, out)
}

// VerifyWebhook checks the webhook signature and returns the body.
// X-Signature = hex(HMAC-SHA256(webhook secret, X-Timestamp + "\n" + body)); timestamps older than 5 minutes are rejected as replays.
func VerifyWebhook(secret string, r *http.Request) ([]byte, error) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}
	ts := r.Header.Get("X-Timestamp")
	ms, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || time.Since(time.UnixMilli(ms)).Abs() > 5*time.Minute {
		return nil, fmt.Errorf("webhook timestamp invalid")
	}
	want := hmacHex(secret, ts+"\n"+string(body))
	if !hmac.Equal([]byte(want), []byte(strings.ToLower(r.Header.Get("X-Signature")))) {
		return nil, fmt.Errorf("webhook signature invalid")
	}
	return body, nil
}

```

```java \[Java]
// DTPG Wallet merchant API — Java 17+ example: request signing + webhook verification (JDK only).
// Request/response bodies are JSON strings; plug in Jackson/Gson as you like.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class DtpgWallet {
    private static final HexFormat HEX = HexFormat.of();
    private static final SecureRandom RANDOM = new SecureRandom();

    private final String baseUrl;
    private final String apiKey;
    private final String secret;
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();

    public DtpgWallet(String baseUrl, String apiKey, String secret) {
        this.baseUrl = baseUrl;
        this.apiKey = apiKey;
        this.secret = secret;
    }

    /** RFC 3986: URLEncoder uses "+" for space and leaves "*" alone; the server expects %20 and %2A, and "~" unescaped. */
    static String rfc3986(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
    }

    /** Sort by key (then by value), RFC 3986-encode, join with &. */
    public static String canonicalQuery(Map<String, List<String>> params) {
        List<String> parts = new ArrayList<>();
        new TreeMap<>(params).forEach((k, vs) -> vs.stream().sorted().forEach(v -> parts.add(rfc3986(k) + "=" + rfc3986(v))));
        return String.join("&", parts);
    }

    static String sha256Hex(byte[] data) {
        try {
            return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(data));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    static String hmacHex(String secret, byte[] msg) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return HEX.formatHex(mac.doFinal(msg));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    /** X-Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nhex(sha256(BODY)))) */
    public static String sign(String secret, String method, String path, String query, String timestamp, String nonce, byte[] body) {
        String toSign = String.join("\n", method, path, query, timestamp, nonce, sha256Hex(body));
        return hmacHex(secret, toSign.getBytes(StandardCharsets.UTF_8));
    }

    /** Sends a signed request; jsonBody may be null. Returns the response body; throws DtpgWalletException on non-200. */
    public String request(String method, String path, Map<String, List<String>> query, String jsonBody) throws Exception {
        byte[] raw = jsonBody == null ? new byte[0] : jsonBody.getBytes(StandardCharsets.UTF_8); // sign exactly what you send
        String q = query == null ? "" : canonicalQuery(query);
        String ts = String.valueOf(System.currentTimeMillis());
        byte[] nb = new byte[16];
        RANDOM.nextBytes(nb);
        String nonce = HEX.formatHex(nb);
        HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path + (q.isEmpty() ? "" : "?" + q)))
                .timeout(Duration.ofSeconds(15))
                .header("Content-Type", "application/json")
                .header("X-Api-Key", apiKey)
                .header("X-Timestamp", ts)
                .header("X-Nonce", nonce)
                .header("X-Signature", sign(secret, method, path, q, ts, nonce, raw))
                .method(method, raw.length == 0 ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofByteArray(raw))
                .build();
        HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
        if (resp.statusCode() != 200) {
            throw new DtpgWalletException(resp.statusCode(), resp.body()); // body: {code, reason, message, metadata}
        }
        return resp.body();
    }

    /** rawBody must be the untouched request body. Rejects timestamps older than 5 minutes. */
    public static boolean verifyWebhook(String secret, String timestamp, String signature, byte[] rawBody) {
        try {
            if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > 5 * 60 * 1000) return false;
        } catch (NumberFormatException e) {
            return false;
        }
        byte[] msg = (timestamp + "\n").getBytes(StandardCharsets.UTF_8);
        byte[] all = new byte[msg.length + rawBody.length];
        System.arraycopy(msg, 0, all, 0, msg.length);
        System.arraycopy(rawBody, 0, all, msg.length, rawBody.length);
        byte[] want = hmacHex(secret, all).getBytes(StandardCharsets.UTF_8);
        byte[] got = (signature == null ? "" : signature.toLowerCase()).getBytes(StandardCharsets.UTF_8);
        return MessageDigest.isEqual(want, got);
    }

    public static final class DtpgWalletException extends Exception {
        public final int status;
        public final String body;

        DtpgWalletException(int status, String body) {
            super(status + " " + body);
            this.status = status;
            this.body = body;
        }
    }
}

```

```py \[Python]
"""DTPG Wallet merchant API — Python 3.8+ example: request signing + webhook verification (requires `requests`)."""
import hashlib
import hmac
import json
import secrets
import time
from urllib.parse import quote

import requests


def _rfc3986(s: str) -> str:
    return quote(s, safe="-_.~")


def canonical_query(params: dict) -> str:
    """Sort by key (then by value), RFC 3986-encode (space = %20), join with &. Values may be lists."""
    pairs = []
    for k, v in (params or {}).items():
        if v is None:
            continue
        for item in v if isinstance(v, (list, tuple)) else [v]:
            pairs.append((str(k), str(item)))
    pairs.sort()
    return "&".join(f"{_rfc3986(k)}={_rfc3986(v)}" for k, v in pairs)


def _hmac_hex(secret: str, msg: bytes) -> str:
    return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()


def sign(secret: str, method: str, path: str, query: str, timestamp: str, nonce: str, body: bytes = b"") -> str:
    """X-Signature = hex(HMAC-SHA256(secret, METHOD\\nPATH\\nQUERY\\nTIMESTAMP\\nNONCE\\nhex(sha256(BODY))))"""
    to_sign = "\n".join([method, path, query, timestamp, nonce, hashlib.sha256(body).hexdigest()])
    return _hmac_hex(secret, to_sign.encode())


class DtpgWalletError(Exception):
    def __init__(self, status: int, body: dict):
        super().__init__(f"{status} {body.get('reason')}: {body.get('message')}")
        self.status = status
        self.reason = body.get("reason")
        self.metadata = body.get("metadata") or {}


class DtpgWallet:
    def __init__(self, api_key: str, secret: str, base_url: str = "https://api.dtpg.net"):
        self.api_key, self.secret, self.base_url = api_key, secret, base_url
        self.session = requests.Session()

    def request(self, method: str, path: str, query: dict = None, body: dict = None) -> dict:
        raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()  # sign exactly what you send
        q = canonical_query(query)
        ts = str(int(time.time() * 1000))
        nonce = secrets.token_hex(16)
        headers = {
            "Content-Type": "application/json",
            "X-Api-Key": self.api_key,
            "X-Timestamp": ts,
            "X-Nonce": nonce,
            "X-Signature": sign(self.secret, method, path, q, ts, nonce, raw),
        }
        url = self.base_url + path + (f"?{q}" if q else "")
        resp = self.session.request(method, url, data=raw or None, headers=headers, timeout=15)
        data = resp.json() if resp.content else {}
        if resp.status_code != 200:
            raise DtpgWalletError(resp.status_code, data)
        return data


def verify_webhook(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    """raw_body must be the untouched request body. Rejects timestamps older than 5 minutes."""
    try:
        if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
            return False
    except (TypeError, ValueError):
        return False
    want = _hmac_hex(secret, timestamp.encode() + b"\n" + raw_body)
    return hmac.compare_digest(want, (signature or "").lower())

```

```js \[Node.js]
// DTPG Wallet merchant API — Node.js (18+) example: request signing + webhook verification (no dependencies).
import crypto from 'node:crypto'

// RFC 3986: encodeURIComponent leaves !'()* unescaped, the server encodes them.
const rfc3986 = (s) =>
  encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())

const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0)

// params: { key: value | value[] }. Sort by key (then by value), RFC 3986-encode, join with &.
export function canonicalQuery(params = {}) {
  const pairs = []
  for (const [k, v] of Object.entries(params)) {
    if (v === undefined || v === null) continue
    for (const item of Array.isArray(v) ? v : [v]) pairs.push([k, String(item)])
  }
  pairs.sort(([ak, av], [bk, bv]) => cmp(ak, bk) || cmp(av, bv))
  return pairs.map(([k, v]) => `${rfc3986(k)}=${rfc3986(v)}`).join('&')
}

const sha256Hex = (data) => crypto.createHash('sha256').update(data).digest('hex')
const hmacHex = (secret, msg) => crypto.createHmac('sha256', secret).update(msg).digest('hex')

// X-Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nhex(sha256(BODY))))
export function sign(secret, method, path, query, timestamp, nonce, body = '') {
  return hmacHex(secret, [method, path, query, timestamp, nonce, sha256Hex(body)].join('\n'))
}

export class DtpgWalletError extends Error {
  constructor(status, body) {
    super(`${status} ${body.reason ?? ''}: ${body.message ?? ''}`)
    Object.assign(this, { status, reason: body.reason, metadata: body.metadata ?? {} })
  }
}

export class DtpgWallet {
  constructor({ baseURL = 'https://api.dtpg.net', apiKey, secret }) {
    Object.assign(this, { baseURL, apiKey, secret })
  }

  async request(method, path, { query, body } = {}) {
    const raw = body === undefined ? '' : JSON.stringify(body) // sign exactly the bytes you send
    const q = canonicalQuery(query)
    const ts = String(Date.now())
    const nonce = crypto.randomBytes(16).toString('hex')
    const res = await fetch(this.baseURL + path + (q ? `?${q}` : ''), {
      method,
      body: raw || undefined,
      headers: {
        'Content-Type': 'application/json',
        'X-Api-Key': this.apiKey,
        'X-Timestamp': ts,
        'X-Nonce': nonce,
        'X-Signature': sign(this.secret, method, path, q, ts, nonce, raw),
      },
    })
    const data = await res.json().catch(() => ({}))
    if (!res.ok) throw new DtpgWalletError(res.status, data)
    return data
  }
}

// rawBody must be the untouched request body (Buffer/string), not re-serialized JSON.
export function verifyWebhook(secret, headers, rawBody) {
  const ts = headers['x-timestamp']
  if (!ts || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false
  const want = Buffer.from(hmacHex(secret, Buffer.concat([Buffer.from(ts + '\n'), Buffer.from(rawBody)])))
  const got = Buffer.from(String(headers['x-signature'] ?? '').toLowerCase())
  return want.length === got.length && crypto.timingSafeEqual(want, got)
}

```

```php \[PHP]
<?php
// DTPG Wallet merchant API — PHP 8+ example: request signing + webhook verification (ext-curl).

final class DtpgWalletException extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $body)
    {
        parent::__construct($status . ' ' . ($body['reason'] ?? '') . ': ' . ($body['message'] ?? ''));
    }

    public function reason(): ?string { return $this->body['reason'] ?? null; }
    public function metadata(): array { return $this->body['metadata'] ?? []; }
}

final class DtpgWallet
{
    public function __construct(
        private string $apiKey,
        private string $secret,
        private string $baseUrl = 'https://api.dtpg.net',
    ) {}

    /** Sort by key (then by value), RFC 3986-encode (rawurlencode), join with &. Values may be arrays. */
    public static function canonicalQuery(array $params): string
    {
        $pairs = [];
        foreach ($params as $k => $v) {
            if ($v === null) continue;
            foreach ((array) $v as $item) $pairs[] = [(string) $k, (string) $item];
        }
        usort($pairs, fn ($a, $b) => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));
        return implode('&', array_map(fn ($p) => rawurlencode($p[0]) . '=' . rawurlencode($p[1]), $pairs));
    }

    /** X-Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nhex(sha256(BODY)))) */
    public static function sign(string $secret, string $method, string $path, string $query, string $ts, string $nonce, string $body = ''): string
    {
        return hash_hmac('sha256', implode("\n", [$method, $path, $query, $ts, $nonce, hash('sha256', $body)]), $secret);
    }

    public function request(string $method, string $path, array $query = [], ?array $body = null): array
    {
        $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // sign exactly what you send
        $q = self::canonicalQuery($query);
        $ts = (string) (int) (microtime(true) * 1000);
        $nonce = bin2hex(random_bytes(16));
        $ch = curl_init($this->baseUrl . $path . ($q !== '' ? "?$q" : ''));
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'X-Api-Key: ' . $this->apiKey,
                'X-Timestamp: ' . $ts,
                'X-Nonce: ' . $nonce,
                'X-Signature: ' . self::sign($this->secret, $method, $path, $q, $ts, $nonce, $raw),
            ],
        ] + ($raw !== '' ? [CURLOPT_POSTFIELDS => $raw] : []));
        $resp = curl_exec($ch);
        if ($resp === false) throw new RuntimeException(curl_error($ch));
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $data = json_decode($resp, true) ?? [];
        if ($status !== 200) throw new DtpgWalletException($status, $data);
        return $data;
    }

    /** $rawBody must be the untouched request body (file_get_contents('php://input')). */
    public static function verifyWebhook(string $secret, ?string $ts, ?string $signature, string $rawBody): bool
    {
        if ($ts === null || !ctype_digit($ts) || abs((int) (microtime(true) * 1000) - (int) $ts) > 5 * 60 * 1000) return false;
        return hash_equals(hash_hmac('sha256', $ts . "\n" . $rawBody, $secret), strtolower($signature ?? ''));
    }
}

```

:::

## 用法

查充值记录（分页）、发起提现（处理 `DUPLICATE_REQUEST`）、接收回调：

::: code-group

```go \[Go]
package dtpgwallet

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"os"
)

// Usage: list deposits, create a withdrawal, handle webhooks.
func Example() {
	c := &Client{BaseURL: "https://api.dtpg.net", APIKey: os.Getenv("DTPG_API_KEY"), Secret: os.Getenv("DTPG_API_SECRET")}
	ctx := context.Background()

	// List deposits. Pagination: pass next_page_token as the next page_token; empty means no more pages.
	var page struct {
		Deposits      []map[string]any `json:"deposits"`
		NextPageToken string           `json:"next_page_token"`
	}
	_ = c.Do(ctx, "GET", "/v1/deposits", url.Values{"user_id": {"u_10001"}, "page_size": {"50"}}, nil, &page)

	// Create a withdrawal. biz_id is the idempotency key; a repeat returns DUPLICATE_REQUEST.
	var w map[string]any
	err := c.Do(ctx, "POST", "/v1/withdraws", nil, map[string]any{
		"user_id": "u_10001", "biz_id": "order-20261109-0001", "chain_code": "TRON", "token_code": "USDT",
		"to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "amount": "12.5",
	}, &w)
	var apiErr *APIError
	if errors.As(err, &apiErr) && apiErr.Reason == "DUPLICATE_REQUEST" {
		fmt.Println("already submitted:", apiErr.Metadata["withdrawId"])
	}

	// Webhook: verify signature → dedupe by event_id → process → return 2xx.
	http.HandleFunc("/dtpg/webhook", func(rw http.ResponseWriter, r *http.Request) {
		body, err := VerifyWebhook(os.Getenv("DTPG_WEBHOOK_SECRET"), r)
		if err != nil {
			rw.WriteHeader(http.StatusUnauthorized)
			return
		}
		var ev struct {
			EventID string          `json:"event_id"`
			Type    string          `json:"type"`
			Data    json.RawMessage `json:"data"`
		}
		_ = json.Unmarshal(body, &ev)
		// switch ev.Type { case "deposit.confirmed": ... case "withdraw.confirmed": ... }
		rw.WriteHeader(http.StatusOK)
	})
}

```

```java \[Java]
// Usage: list deposits, create a withdrawal. For webhooks, call DtpgWallet.verifyWebhook with the raw body
// (e.g. Spring: @RequestBody byte[] body, @RequestHeader("X-Timestamp"), @RequestHeader("X-Signature")),
// dedupe by event_id, then return 2xx.
import java.util.List;
import java.util.Map;

public class Example {
    public static void main(String[] args) throws Exception {
        DtpgWallet wallet = new DtpgWallet("https://api.dtpg.net", System.getenv("DTPG_API_KEY"), System.getenv("DTPG_API_SECRET"));

        // Pagination: pass next_page_token as page_token; empty means no more pages.
        String page = wallet.request("GET", "/v1/deposits", Map.of("user_id", List.of("u_10001"), "page_size", List.of("50")), null);
        System.out.println(page);

        // biz_id is the idempotency key; a repeat returns 409 DUPLICATE_REQUEST.
        String body = """
                {"user_id":"u_10001","biz_id":"order-20261109-0001","chain_code":"TRON","token_code":"USDT",\
                "to_address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","amount":"12.5"}""";
        try {
            System.out.println(wallet.request("POST", "/v1/withdraws", null, body));
        } catch (DtpgWallet.DtpgWalletException e) {
            System.out.println(e.status + " " + e.body);
        }
    }
}

```

```py \[Python（Flask）]
"""Usage: list deposits, create a withdrawal, handle webhooks (Flask)."""
import json
import os

from flask import Flask, request

from dtpg_wallet import DtpgWallet, DtpgWalletError, verify_webhook

wallet = DtpgWallet(os.environ["DTPG_API_KEY"], os.environ["DTPG_API_SECRET"])

# Pagination: pass next_page_token as page_token; empty means no more pages.
token = None
while True:
    page = wallet.request("GET", "/v1/deposits", query={"user_id": "u_10001", "page_size": 50, "page_token": token})
    for d in page.get("deposits", []):
        print(d["deposit_id"], d.get("status"), d.get("amount"))
    token = page.get("next_page_token")
    if not token:
        break

# biz_id is the idempotency key; a repeat returns DUPLICATE_REQUEST.
try:
    w = wallet.request("POST", "/v1/withdraws", body={
        "user_id": "u_10001", "biz_id": "order-20261109-0001", "chain_code": "TRON",
        "token_code": "USDT", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "amount": "12.5",
    })
    print(w["withdraw_id"], w.get("status"))
except DtpgWalletError as e:
    if e.reason != "DUPLICATE_REQUEST":
        raise
    print("already submitted:", e.metadata.get("withdrawId"))

app = Flask(__name__)


@app.post("/dtpg/webhook")
def webhook():
    raw = request.get_data()  # raw bytes, not request.json
    if not verify_webhook(os.environ["DTPG_WEBHOOK_SECRET"], request.headers.get("X-Timestamp"),
                          request.headers.get("X-Signature"), raw):
        return "", 401
    event = json.loads(raw)
    # Dedupe by event["event_id"], then switch on event["type"]: deposit.confirmed / withdraw.confirmed / ...
    return "", 200

```

```js \[Node.js（Express）]
// Usage: list deposits, create a withdrawal, handle webhooks (Express).
import express from 'express'
import { DtpgWallet, DtpgWalletError, verifyWebhook } from './dtpg-wallet.mjs'

const wallet = new DtpgWallet({ apiKey: process.env.DTPG_API_KEY, secret: process.env.DTPG_API_SECRET })

// Pagination: pass next_page_token as page_token; empty means no more pages.
let pageToken = ''
do {
  const page = await wallet.request('GET', '/v1/deposits', { query: { user_id: 'u_10001', page_size: 50, page_token: pageToken || undefined } })
  for (const d of page.deposits ?? []) console.log(d.deposit_id, d.status, d.amount)
  pageToken = page.next_page_token ?? ''
} while (pageToken)

// biz_id is the idempotency key; a repeat returns DUPLICATE_REQUEST.
try {
  const w = await wallet.request('POST', '/v1/withdraws', {
    body: { user_id: 'u_10001', biz_id: 'order-20261109-0001', chain_code: 'TRON', token_code: 'USDT', to_address: 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf', amount: '12.5' },
  })
  console.log(w.withdraw_id, w.status)
} catch (e) {
  if (e instanceof DtpgWalletError && e.reason === 'DUPLICATE_REQUEST') console.log('already submitted:', e.metadata.withdrawId)
  else throw e
}

// Webhook: keep the raw body for signature verification.
const app = express()
app.post('/dtpg/webhook', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyWebhook(process.env.DTPG_WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(401)
  const event = JSON.parse(req.body.toString('utf8'))
  // Dedupe by event.event_id, then switch on event.type: deposit.confirmed / withdraw.confirmed / ...
  res.sendStatus(200)
})
app.listen(3000)

```

```php \[PHP]
<?php
// Usage: list deposits, create a withdrawal (CLI), or handle a webhook (web request).
require __DIR__ . '/DtpgWallet.php';

if (PHP_SAPI !== 'cli') {
    // Webhook endpoint: verify → dedupe by event_id → process → 2xx.
    $raw = file_get_contents('php://input');
    if (!DtpgWallet::verifyWebhook(getenv('DTPG_WEBHOOK_SECRET'), $_SERVER['HTTP_X_TIMESTAMP'] ?? null, $_SERVER['HTTP_X_SIGNATURE'] ?? null, $raw)) {
        http_response_code(401);
        exit;
    }
    $event = json_decode($raw, true);
    // switch ($event['type']) { case 'deposit.confirmed': ...; case 'withdraw.confirmed': ...; }
    http_response_code(200);
    exit;
}

$wallet = new DtpgWallet(getenv('DTPG_API_KEY'), getenv('DTPG_API_SECRET'));

// Pagination: pass next_page_token as page_token; empty means no more pages.
$token = null;
do {
    $page = $wallet->request('GET', '/v1/deposits', ['user_id' => 'u_10001', 'page_size' => 50, 'page_token' => $token]);
    foreach ($page['deposits'] ?? [] as $d) echo $d['deposit_id'], ' ', $d['status'] ?? '', ' ', $d['amount'] ?? '', PHP_EOL;
    $token = $page['next_page_token'] ?? null;
} while ($token);

// biz_id is the idempotency key; a repeat returns DUPLICATE_REQUEST.
try {
    $w = $wallet->request('POST', '/v1/withdraws', [], [
        'user_id' => 'u_10001', 'biz_id' => 'order-20261109-0001', 'chain_code' => 'TRON',
        'token_code' => 'USDT', 'to_address' => 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf', 'amount' => '12.5',
    ]);
    echo $w['withdraw_id'], ' ', $w['status'] ?? '', PHP_EOL;
} catch (DtpgWalletException $e) {
    if ($e->reason() !== 'DUPLICATE_REQUEST') throw $e;
    echo 'already submitted: ', $e->metadata()['withdrawId'] ?? '', PHP_EOL;
}

```

:::

## 自测

用下面的测试数据检查你的实现：

| 用例 | 期望签名 |
|---|---|
| `GET /v1/deposits`，查询 `user_id=u 1`、`chain_code=TRON`、`status=PENDING,CONFIRMED`、`note=a+b!` | `d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86` |
| `POST /v1/withdraws`，body 见签名认证页 | `381b9e20fc9b2e0cd97ecb003686acd472db596757b674e56df9e038d708867c` |
| 回调，密钥 `whsec-test` | `31c194e933a5f62c752bace322b571f8e281edf589c97c5b672ac245e8a54664` |

API secret 为 `test-secret`，`X-Timestamp` 为 `1762650000000`，`X-Nonce` 为 `0123456789abcdef0123`。
