--- url: /en/guide/authentication.md --- # Authentication Every `/v1/**` endpoint is authenticated with an HMAC-SHA256 request signature. There are no sessions or bearer tokens. ## Headers | Header | Description | |---|---| | `X-Api-Key` | API key | | `X-Timestamp` | Current time in Unix **milliseconds**. Rejected if more than ±30 s from server time (`REQUEST_EXPIRED`) | | `X-Nonce` | Random string of 16–64 characters; must not repeat for the same key within 5 minutes (`NONCE_REPLAYED`). Hex of 16 random bytes is recommended | | `X-Signature` | The signature (below), lowercase hex | | `Content-Type` | `application/json` when there is a body | ## String to sign Join these 6 parts with `\n` (line feed, 0x0A), **without a trailing newline**: ```text METHOD PATH CANONICAL_QUERY TIMESTAMP NONCE hex(sha256(BODY)) ``` | Part | Rule | |---|---| | `METHOD` | Uppercase, e.g. `GET`, `POST` | | `PATH` | URL path without host or query string, exactly as sent (percent-encoded), e.g. `/v1/wallets/u_10001/addresses` | | `CANONICAL_QUERY` | Sort parameters by key, then by value for repeated keys; RFC 3986-encode keys and values (only `A-Z a-z 0-9 - _ . ~` stay literal, space is `%20`, not `+`); join with `&`. Empty string when there is no query | | `TIMESTAMP` | Same as `X-Timestamp` | | `NONCE` | Same as `X-Nonce` | | `hex(sha256(BODY))` | Lowercase hex SHA-256 of the raw body bytes. Without a body (e.g. GET) it is the hash of the empty string, `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | ```text X-Signature = hex(HMAC-SHA256(API secret, string to sign)) ``` ::: warning The two most common mistakes 1. **Sign the exact bytes you send.** Serialize the JSON to a string once, hash that string and send that same string as the body; don't sign one serialization and let your HTTP library produce another. 2. **Send the canonical query string itself** as the URL query, so what you sign and what you send can never differ in order or encoding. ::: ## Worked example API secret `test-secret`, `X-Timestamp: 1762650000000`, `X-Nonce: 0123456789abcdef0123`: ```http GET /v1/deposits?user_id=u%201&chain_code=TRON&status=PENDING&status=CONFIRMED¬e=a%2Bb%21 ``` Canonical query: ```text chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201 ``` String to sign: ```text GET /v1/deposits chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201 1762650000000 0123456789abcdef0123 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` Signature: `d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86` With a body (`POST /v1/withdraws`, same timestamp and nonce): ```json {"user_id":"u1","chain_code":"TRON","token_code":"USDT","to_address":"TXYZ","amount":"12.5","biz_id":"order-1"} ``` Signature: `381b9e20fc9b2e0cd97ecb003686acd472db596757b674e56df9e038d708867c` Use these two vectors to test your implementation. ## Scopes and IP allowlist Each API key has scopes; the scope each endpoint needs is listed in the [API reference](/en/reference/): | Scope | Allows | |---|---| | `read` | All queries, resending events | | `deposit` | Creating deposit addresses, rescans | | `withdraw` | Creating withdrawals | A missing scope returns `PERMISSION_DENIED`; a source IP outside the key's allowlist returns `IP_NOT_ALLOWED`. Use a dedicated `withdraw` key for withdrawals and allow only your servers' egress IPs. ## Authentication errors | reason | HTTP | Cause | |---|---|---| | `MERCHANT_AUTH_FAILED` | 401 | Missing signature headers, unknown or disabled key, or disabled merchant | | `SIGNATURE_INVALID` | 401 | Wrong signature — compare your string to sign with the example above, part by part | | `REQUEST_EXPIRED` | 401 | Timestamp missing, not in milliseconds, or more than 30 s off (check NTP) | | `NONCE_REPLAYED` | 401 | Nonce reused, or not 16–64 characters long | | `IP_NOT_ALLOWED` | 403 | Source IP not in the allowlist | | `PERMISSION_DENIED` | 403 | The key lacks the scope this endpoint requires | --- --- url: /en/reference/catalog.md --- # Catalog Catalog: supported chains and assets, address validation ## Validate address ```http POST /v1/addresses/validate ``` API key scope: `read` Validate address: whether the format is valid and whether it is an in-platform address (withdrawals to in-platform addresses are internal transfers, not on-chain) **Request body** | Field | Type | Description | |---|---|---| | `address` | string | Address | | `chain_code` | string | Chain code | Request example: ```json { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON" } ``` **Response** (200, `AddressValidation`) | Field | Type | Description | |---|---|---| | `internal` | boolean | Whether it is a user deposit address on this platform; if so, withdrawals to it are internal transfers (instant, not on-chain, no fee) | | `normalized_address` | string | Normalized address (lowercase for EVM); empty when invalid | | `valid` | boolean | Whether the format is valid | Response example: ```json { "internal": false, "normalized_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "valid": false } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Supported assets ```http GET /v1/assets ``` API key scope: `read` Supported assets: chain × token (decimals, contract, minimum deposit / withdrawal, fee); filterable by chain **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `chain_code` | query | string | no | Chain code: optional; omit to return assets on all enabled chains | **Response** (200, `AssetList`) | Field | Type | Description | |---|---|---| | `assets` | object (Asset)\[] | | | `assets[].chain_code` | string | | | `assets[].contract_address` | string | Contract address; empty for native coins | | `assets[].decimals` | integer | | | `assets[].deposit_enabled` | boolean | | | `assets[].explorer_url_template` | string | Block explorer transaction URL template; replace {txHash} with the transaction hash | | `assets[].min_deposit_amount` | string | Amounts below are decimal strings in the main unit | | `assets[].min_withdraw_amount` | string | | | `assets[].token_code` | string | Token code, e.g. USDT | | `assets[].token_name` | string | | | `assets[].withdraw_enabled` | boolean | | | `assets[].withdraw_fee` | string | | Response example: ```json { "assets": [ { "chain_code": "TRON", "contract_address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6, "deposit_enabled": false, "explorer_url_template": "https://tronscan.org/#/transaction/{txHash}", "min_deposit_amount": "1", "min_withdraw_amount": "10", "token_code": "USDT", "token_name": "Tether USD", "withdraw_enabled": false, "withdraw_fee": "1" } ] } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Supported chains ```http GET /v1/chains ``` API key scope: `read` Supported chains: only enabled chains are returned **Response** (200, `ChainList`) | Field | Type | Description | |---|---|---| | `chains` | object (Chain)\[] | | | `chains[].code` | string | Chain code: TRON / ETH / BSC | | `chains[].evm_chain_id` | string | EVM chainId (e.g. "1", "56"); empty for TRON | | `chains[].name` | string | | Response example: ```json { "chains": [ { "code": "TRON", "evm_chain_id": "", "name": "TRON" } ] } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). --- --- url: /en/guide/examples.md --- # Code examples One file per language: **request signing + sending + webhook verification**, standard library only (Python uses `requests`). Copy them into your project as-is. Every implementation is verified against the test vectors on the [Authentication](./authentication) and [Webhooks](./webhooks) pages. ## Client ::: 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> params) { List 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> 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 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] 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 ?? '')); } } ``` ::: ## Usage List deposits (paginated), create a withdrawal (handling `DUPLICATE_REQUEST`), receive webhooks: ::: 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] 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; } ``` ::: ## Self-test Check your implementation with these vectors: | Case | Expected signature | |---|---| | `GET /v1/deposits`, query `user_id=u 1`, `chain_code=TRON`, `status=PENDING,CONFIRMED`, `note=a+b!` | `d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86` | | `POST /v1/withdraws`, body on the Authentication page | `381b9e20fc9b2e0cd97ecb003686acd472db596757b674e56df9e038d708867c` | | Webhook, secret `whsec-test` | `31c194e933a5f62c752bace322b571f8e281edf589c97c5b672ac245e8a54664` | API secret `test-secret`, `X-Timestamp` `1762650000000`, `X-Nonce` `0123456789abcdef0123`. --- --- url: /en/guide/conventions.md --- # Conventions ## Requests and responses * HTTPS only; request and response bodies are UTF-8 JSON. * Field names are `snake_case`. * On success the HTTP status is 200 and the body **is the resource itself** (no `{code, data}` wrapper). On failure an [error body](./errors) is returned. * Fields whose value is `false`, `0` or an empty string **may be omitted** from responses — treat them as the default. * New fields may be added; ignore fields you don't recognise. ## Numbers and time | Kind | Representation | |---|---| | Time | Unix **milliseconds** as a JSON string, e.g. `"1762650000000"`; `0` means "not yet" | | int64 (IDs, block numbers, …) | JSON **strings**, to avoid JavaScript precision loss, e.g. `"deposit_id": "1987654321098752"` | | Amounts | Both forms: `amount` is a decimal string in the main unit (e.g. `"12.5"`), `amount_raw` an integer string in the smallest unit (e.g. `"12500000"`, together with `decimals`). **Never use floating point for amounts** | ## Pagination List endpoints use cursor pagination: | Parameter / field | Description | |---|---| | `page_size` | Items per page, default 20, max 100 (larger values are treated as 100) | | `page_token` | Omit for the first page; then pass the previous `next_page_token` | | `next_page_token` | Opaque string — **pass it back unchanged**; empty means no more pages | Responses look like `{"deposits": [...], "next_page_token": "..."}`, with the plural resource name as the list field. Keep the other filters unchanged while paging. ## Idempotency | Endpoint | How | |---|---| | `POST /v1/wallets/{user_id}/addresses` | Naturally idempotent — the same user and chain always get the same address | | `POST /v1/withdraws` | `biz_id` (your order number) is unique per merchant; a repeat returns `409 DUPLICATE_REQUEST` with the existing withdrawal in `metadata.withdrawId` | | `POST /v1/deposits/rescan` | Already-detected deposits are never credited twice | When the outcome is unknown (e.g. a network timeout), retry with the same `biz_id` or look it up with `GET /v1/withdraws?biz_id=` — **never retry with a new order number**. ## Chain and token codes Chain codes: `TRON`, `ETH`, `BSC` (authoritative list: `GET /v1/chains`). Token codes such as `USDT`; whether a token can be deposited / withdrawn on a chain, plus minimums and fees, come from `GET /v1/assets`. --- --- url: /en/reference/deposits.md --- # Deposits Deposit records and rescans ## List deposits ```http GET /v1/deposits ``` API key scope: `read` List deposits: ordered by deposit_id descending, cursor pagination **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `user_id` | query | string | no | User ID: optional | | `chain_code` | query | string | no | Chain code: optional | | `token_code` | query | string | no | Token code: optional | | `status` | query | string | no | Status: optional | | `tx_hash` | query | string | no | Transaction hash: optional | | `start_time` | query | string | no | Start time: by detection time, Unix milliseconds, inclusive | | `end_time` | query | string | no | End time: by detection time, Unix milliseconds, inclusive | | `page_size` | query | integer | no | Page size: default 20, max 100 (larger values are treated as 100) | | `page_token` | query | string | no | Page token: the next_page_token from the previous page; omit for the first page. Opaque — pass it back unchanged | **Response** (200, `DepositPage`) | Field | Type | Description | |---|---|---| | `deposits` | object (Deposit)\[] | | | `deposits[].amount` | string | Amount (main unit) | | `deposits[].amount_raw` | string | Amount (integer, smallest unit) | | `deposits[].block_number` | string | | | `deposits[].chain_code` | string | | | `deposits[].confirm_time` | string | Confirmation / credit time (Unix milliseconds); 0 if not confirmed | | `deposits[].confirmations` | integer | | | `deposits[].create_time` | string | Detection time (Unix milliseconds) | | `deposits[].decimals` | integer | | | `deposits[].deposit_id` | string | | | `deposits[].from_address` | string | Sender address; for internal transfers, the platform's outbound hot wallet address | | `deposits[].internal` | boolean | Whether it is an internal transfer (the sender is another user on the platform; not on-chain, credited instantly) | | `deposits[].log_index` | integer | Log index within the transaction (distinguishes multiple transfers in one transaction) | | `deposits[].status` | string | PENDING (confirming, not credited) / CONFIRMED (credited) / FAILED / REORGED / BELOW_MIN (below the minimum deposit; recorded but not credited) | | `deposits[].to_address` | string | | | `deposits[].token_code` | string | | | `deposits[].tx_hash` | string | On-chain transaction hash; empty for internal transfers | | `deposits[].user_id` | string | | | `next_page_token` | string | page_token for the next page; empty means no more pages | Response example: ```json { "deposits": [ { "amount": "12.5", "amount_raw": "12500000", "block_number": "77123456", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "deposit_id": "1987654321098752", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "log_index": 0, "status": "PENDING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ], "next_page_token": "" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Get deposit ```http GET /v1/deposits/{deposit_id} ``` API key scope: `read` Get deposit **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `deposit_id` | path | string | yes | Deposit ID | **Response** (200, `Deposit`) | Field | Type | Description | |---|---|---| | `amount` | string | Amount (main unit) | | `amount_raw` | string | Amount (integer, smallest unit) | | `block_number` | string | | | `chain_code` | string | | | `confirm_time` | string | Confirmation / credit time (Unix milliseconds); 0 if not confirmed | | `confirmations` | integer | | | `create_time` | string | Detection time (Unix milliseconds) | | `decimals` | integer | | | `deposit_id` | string | | | `from_address` | string | Sender address; for internal transfers, the platform's outbound hot wallet address | | `internal` | boolean | Whether it is an internal transfer (the sender is another user on the platform; not on-chain, credited instantly) | | `log_index` | integer | Log index within the transaction (distinguishes multiple transfers in one transaction) | | `status` | string | PENDING (confirming, not credited) / CONFIRMED (credited) / FAILED / REORGED / BELOW_MIN (below the minimum deposit; recorded but not credited) | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | On-chain transaction hash; empty for internal transfers | | `user_id` | string | | Response example: ```json { "amount": "12.5", "amount_raw": "12500000", "block_number": "77123456", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "deposit_id": "1987654321098752", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "log_index": 0, "status": "PENDING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Rescan by transaction hash ```http POST /v1/deposits/rescan ``` API key scope: `deposit` Rescan by transaction hash: re-detect deposits to this user in the transaction (use when a deposit was missed); already-detected deposits are never credited twice **Request body** | Field | Type | Description | |---|---|---| | `chain_code` | string | Chain code | | `tx_hash` | string | Transaction hash | | `user_id` | string | User ID: the merchant's own user identifier | Request example: ```json { "chain_code": "TRON", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ``` **Response** (200, `RescanResult`) | Field | Type | Description | |---|---|---| | `chain_code` | string | | | `items` | object (RescanResultItem)\[] | | | `items[].amount` | string | Amount (main unit) | | `items[].deposit_id` | string | Deposit ID; empty if the transfer was skipped (e.g. native coin, unregistered token) | | `items[].log_index` | integer | | | `items[].status` | string | Deposit status; SKIPPED if skipped | | `items[].to_address` | string | | | `message` | string | | | `result` | string | ACCEPTED: at least one deposit was recorded and awaits confirmation; SKIPPED: nothing to credit | | `tx_hash` | string | | Response example: ```json { "chain_code": "TRON", "items": [ { "amount": "12.5", "deposit_id": "1987654321098752", "log_index": 0, "status": "SKIPPED", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" } ], "message": "amount below minimum", "result": "", "tx_hash": "3f1c0e…(交易哈希)" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). --- --- url: /en.md --- --- --- url: /en/reference.md --- # Endpoints Base URL: `https://api.dtpg.net`. Every request must be signed; see [Authentication](/en/guide/authentication). Machine-readable OpenAPI 3 definition: [openapi.yaml](/openapi.yaml) (import into Postman / Apifox / code generators). | Method | Path | Summary | Scope | |---|---|---|---| | `POST` | [`/v1/addresses/validate`](/en/reference/catalog#validate-address) | Validate address | `read` | | `GET` | [`/v1/assets`](/en/reference/catalog#supported-assets) | Supported assets | `read` | | `GET` | [`/v1/chains`](/en/reference/catalog#supported-chains) | Supported chains | `read` | | `GET` | [`/v1/wallets/{user_id}/addresses`](/en/reference/wallets#all-deposit-addresses-of-a-user) | All deposit addresses of a user | `read` | | `POST` | [`/v1/wallets/{user_id}/addresses`](/en/reference/wallets#get-or-create-a-deposit-address) | Get or create a deposit address | `deposit` | | `GET` | [`/v1/wallets/{user_id}/balances`](/en/reference/wallets#user-balances) | User balances | `read` | | `GET` | [`/v1/deposits`](/en/reference/deposits#list-deposits) | List deposits | `read` | | `GET` | [`/v1/deposits/{deposit_id}`](/en/reference/deposits#get-deposit) | Get deposit | `read` | | `POST` | [`/v1/deposits/rescan`](/en/reference/deposits#rescan-by-transaction-hash) | Rescan by transaction hash | `deposit` | | `GET` | [`/v1/withdraws`](/en/reference/withdraws#list-withdrawals) | List withdrawals | `read` | | `POST` | [`/v1/withdraws`](/en/reference/withdraws#create-withdrawal) | Create withdrawal | `withdraw` | | `GET` | [`/v1/withdraws/{withdraw_id}`](/en/reference/withdraws#get-withdrawal) | Get withdrawal | `read` | | `GET` | [`/v1/events`](/en/reference/events#list-events) | List events | `read` | | `POST` | [`/v1/events/{event_id}/resend`](/en/reference/events#resend-event) | Resend event | `read` | --- --- url: /en/guide/errors.md --- # Errors On failure you get a non-200 status and a unified error body: ```json { "code": 400, "reason": "AMOUNT_BELOW_MIN", "message": "amount below minimum", "metadata": { "minAmount": "10" } } ``` | Field | Description | |---|---| | `code` | HTTP status code | | `reason` | Stable error identifier — **branch on this** | | `message` | English text for developers; may change, don't parse it | | `metadata` | Dynamic parameters (string → string), see below | ## Reasons | reason | HTTP | Meaning | metadata | |---|---|---|---| | `BAD_REQUEST` | 400 | Invalid parameters (missing field, bad format, …) | | | `VALIDATOR` | 400 | Field validation failed; `message` names the field | | | `TOO_MANY_REQUESTS` | 429 | Too many requests; retry later | | | `NOT_FOUND` | 404 | Resource not found | | | `MERCHANT_AUTH_FAILED` | 401 | Invalid / disabled API key, disabled merchant, or missing signature headers | | | `SIGNATURE_INVALID` | 401 | Signature mismatch | | | `REQUEST_EXPIRED` | 401 | `X-Timestamp` missing or outside ±30 s | | | `NONCE_REPLAYED` | 401 | `X-Nonce` reused or of invalid length | | | `IP_NOT_ALLOWED` | 403 | Source IP not in the allowlist | | | `PERMISSION_DENIED` | 403 | The API key lacks the required scope | | | `CHAIN_NOT_SUPPORTED` | 400 | Chain unknown or disabled | | | `TOKEN_NOT_SUPPORTED` | 400 | Token unknown or not configured on this chain | | | `ADDRESS_INVALID` | 400 | Malformed address | | | `USER_NOT_FOUND` | 404 | User wallet does not exist (no address requested yet) | | | `INSUFFICIENT_BALANCE` | 400 | Insufficient user balance (internal transfer) | | | `TX_NOT_CREDITABLE` | 400 | Rescan: transaction not found, not yet on-chain, failed, or not paying a platform address | | | `RECREDIT_REJECTED` | 403 | Rescan: the receiving address does not belong to this user | | | `WITHDRAW_DISABLED` | 403 | Withdrawals are not enabled for the merchant | | | `WITHDRAW_NOT_ALLOWED` | 400 | Withdrawals of this token on this chain are not allowed | | | `AMOUNT_BELOW_MIN` | 400 | Below the minimum withdrawal amount | `minAmount` | | `DUPLICATE_REQUEST` | 409 | `biz_id` already exists | `withdrawId`, `status` | | `INVALID_STATE` | 409 | The current state does not allow this operation | | | `HOT_WALLET_INSUFFICIENT` | 409 | Platform outbound wallet balance is insufficient; retry later or contact us | | | `SIGNER_UNAVAILABLE` | 503 | Signing service temporarily unavailable; retry later | | | `CHAIN_RPC_UNAVAILABLE` | 503 | Chain node temporarily unavailable; retry later | | | `INTERNAL` | 500 | Internal server error | | ## Retrying * **5xx and network errors**: safe to retry. For `POST /v1/withdraws` always retry with the **same `biz_id`**. * **4xx**: fix the request first; an identical retry will fail again. `DUPLICATE_REQUEST` means an earlier attempt succeeded — continue with `metadata.withdrawId`. * New reasons may be added; fall back to the HTTP status for unknown ones. --- --- url: /en/reference/events.md --- # Events (webhooks) Webhook events: backfill and resend Use when a webhook was missed. Event types: deposit.confirmed; withdraw.broadcasted / confirmed / failed / rejected. ## List events ```http GET /v1/events ``` API key scope: `read` List events: newest first, cursor pagination; filter by delivery status to find undelivered ones (e.g. FAILED) **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `type` | query | string | no | Event type: optional | | `delivery_status` | query | string | no | Delivery status: optional | | `start_time` | query | string | no | Start time: by event time, Unix milliseconds, inclusive | | `end_time` | query | string | no | End time: by event time, Unix milliseconds, inclusive | | `page_size` | query | integer | no | Page size: default 20, max 100 (larger values are treated as 100) | | `page_token` | query | string | no | Page token: the next_page_token from the previous page; omit for the first page. Opaque — pass it back unchanged | **Response** (200, `EventPage`) | Field | Type | Description | |---|---|---| | `events` | object (Event)\[] | | | `events[].created_at` | string | Event time (Unix milliseconds) | | `events[].data` | object | Event data: identical to the resource returned by the matching GET endpoint (deposit.confirmed → GET /v1/deposits/{deposit_id}), as a snapshot at event time | | `events[].delivery_status` | string | Delivery status: PENDING (queued) / RETRY (failed, waiting to retry) / SUCCESS (merchant returned 2xx) / FAILED (retries exhausted) | | `events[].event_id` | string | Event ID (the X-Webhook-Id header and event_id in the body) | | `events[].last_http_status` | integer | HTTP status of the last delivery; 0 if none | | `events[].retry_count` | integer | Retry rounds (up to 3 immediate retries per round, then backoff until the next round; marked FAILED after the limit) | | `events[].type` | string | Event type: deposit.confirmed / withdraw.broadcasted / withdraw.confirmed / withdraw.failed / withdraw.rejected | | `next_page_token` | string | page_token for the next page; empty means no more pages | Response example: ```json { "events": [ { "created_at": "1762650000000", "data": {}, "delivery_status": "PENDING", "event_id": "evt_1987654321098754", "last_http_status": 0, "retry_count": 0, "type": "deposit.confirmed" } ], "next_page_token": "" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Resend event ```http POST /v1/events/{event_id}/resend ``` API key scope: `read` Resend event: immediately redeliver once using the merchant's current webhook URL and secret (event_id is unchanged; dedupe by it) **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `event_id` | path | string | yes | Event ID: starts with evt\_ | **Request body** | Field | Type | Description | |---|---|---| | `event_id` | string | Event ID: starts with evt\_ | Request example: ```json { "event_id": "evt_1987654321098754" } ``` **Response** (200, `Event`) | Field | Type | Description | |---|---|---| | `created_at` | string | Event time (Unix milliseconds) | | `data` | object | Event data: identical to the resource returned by the matching GET endpoint (deposit.confirmed → GET /v1/deposits/{deposit_id}), as a snapshot at event time | | `delivery_status` | string | Delivery status: PENDING (queued) / RETRY (failed, waiting to retry) / SUCCESS (merchant returned 2xx) / FAILED (retries exhausted) | | `event_id` | string | Event ID (the X-Webhook-Id header and event_id in the body) | | `last_http_status` | integer | HTTP status of the last delivery; 0 if none | | `retry_count` | integer | Retry rounds (up to 3 immediate retries per round, then backoff until the next round; marked FAILED after the limit) | | `type` | string | Event type: deposit.confirmed / withdraw.broadcasted / withdraw.confirmed / withdraw.failed / withdraw.rejected | Response example: ```json { "created_at": "1762650000000", "data": {}, "delivery_status": "PENDING", "event_id": "evt_1987654321098754", "last_http_status": 0, "retry_count": 0, "type": "deposit.confirmed" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). --- --- url: /en/guide/quickstart.md --- # Quickstart ## 1. Get credentials After your merchant account is opened, the platform provides: | Credential | Purpose | |---|---| | API key + API secret | Sign API requests. The secret is shown only once — store it safely. Create keys per scope (`read` / `deposit` / `withdraw`) and bind an IP allowlist | | Webhook URL + webhook secret | We sign every event we push to your URL with the webhook secret; verify it on your side | Base URL: `https://api.dtpg.net` ## 2. Your first request List supported chains. Every request carries 4 signature headers — see [Authentication](./authentication); ready-made code is in [Code examples](./examples). ```bash # 1. Your credentials API_KEY="your-api-key" API_SECRET="your-api-secret" # 2. This request METHOD="GET" URI_PATH="/v1/chains" QUERY="" # canonical query string; empty if none BODY="" # request body; empty for GET TIMESTAMP=$(( $(date +%s) * 1000 )) # now, Unix milliseconds NONCE=$(openssl rand -hex 16) # 32-char random string # 3. String to sign: 6 parts joined by newlines BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}') STRING_TO_SIGN=$(printf '%s\n%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$URI_PATH" "$QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH") # 4. Signature = hex(HMAC-SHA256(API secret, string to sign)) SIGNATURE=$(printf '%s' "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}') # 5. Send it curl "https://api.dtpg.net${URI_PATH}" \ -H "X-Api-Key: $API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Nonce: $NONCE" \ -H "X-Signature: $SIGNATURE" ``` ```json { "chains": [{ "code": "TRON", "name": "TRON" }] } ``` ## 3. Typical integration 1. **Assign a deposit address**: `POST /v1/wallets/{user_id}/addresses` (idempotent — safe to call before every display). 2. **Wait for the `deposit.confirmed` webhook**: dedupe by `event_id`, then credit the user. Missed one? Backfill with `GET /v1/events`. 3. **Create a withdrawal**: `POST /v1/withdraws` with your own order number as `biz_id` (idempotency key). 4. **Track it**: webhooks `withdraw.broadcasted` → `withdraw.confirmed` (or `withdraw.failed` / `withdraw.rejected`), or poll `GET /v1/withdraws/{withdraw_id}`. ::: tip For AI / coding assistants Give [`/llms-full.txt`](/llms-full.txt) or [`/openapi.yaml`](/openapi.yaml) to your AI assistant and it can write the integration for you. Every page also has a "Copy as Markdown" button under its title. ::: --- --- url: /en/reference/wallets.md --- # User wallets User wallets: deposit addresses and balances ## All deposit addresses of a user ```http GET /v1/wallets/{user_id}/addresses ``` API key scope: `read` All deposit addresses of a user; an empty list if the user does not exist **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `user_id` | path | string | yes | User ID: the merchant's own user identifier | **Response** (200, `AddressList`) | Field | Type | Description | |---|---|---| | `addresses` | object (Address)\[] | | | `addresses[].address` | string | | | `addresses[].chain_code` | string | | | `addresses[].create_time` | string | Creation time (Unix milliseconds) | | `addresses[].status` | string | ACTIVE | | `addresses[].user_id` | string | | Response example: ```json { "addresses": [ { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON", "create_time": "1762650000000", "status": "ACTIVE", "user_id": "u_10001" } ] } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Get or create a deposit address ```http POST /v1/wallets/{user_id}/addresses ``` API key scope: `deposit` Get or create a deposit address: creates the user wallet if missing and derives the address for the chain if missing; idempotent — repeated calls return the same address **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `user_id` | path | string | yes | User ID: the merchant's own user identifier | **Request body** | Field | Type | Description | |---|---|---| | `chain_code` | string | Chain code | | `master_key_id` | string | Master key ID: optional; only takes effect when the user wallet is first created; defaults to the merchant's current ACTIVE master key | | `user_id` | string | User ID: the merchant's own user identifier | Request example: ```json { "chain_code": "TRON", "master_key_id": "", "user_id": "u_10001" } ``` **Response** (200, `Address`) | Field | Type | Description | |---|---|---| | `address` | string | | | `chain_code` | string | | | `create_time` | string | Creation time (Unix milliseconds) | | `status` | string | ACTIVE | | `user_id` | string | | Response example: ```json { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON", "create_time": "1762650000000", "status": "ACTIVE", "user_id": "u_10001" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## User balances ```http GET /v1/wallets/{user_id}/balances ``` API key scope: `read` User balances: available / frozen per asset (only credited, confirmed deposits and internal transfers); an empty list if the user does not exist **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `user_id` | path | string | yes | User ID: the merchant's own user identifier | **Response** (200, `BalanceList`) | Field | Type | Description | |---|---|---| | `balances` | object (Balance)\[] | | | `balances[].available` | string | Available (main unit) | | `balances[].available_raw` | string | Available (integer, smallest unit) | | `balances[].chain_code` | string | | | `balances[].decimals` | integer | | | `balances[].frozen` | string | Frozen (main unit) | | `balances[].frozen_raw` | string | Frozen (integer, smallest unit) | | `balances[].token_code` | string | | | `balances[].update_time` | string | Last change time (Unix milliseconds) | Response example: ```json { "balances": [ { "available": "100.5", "available_raw": "100500000", "chain_code": "TRON", "decimals": 6, "frozen": "0", "frozen_raw": "0", "token_code": "USDT", "update_time": "1762650000000" } ] } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). --- --- url: /en/guide/webhooks.md --- # Webhooks (events) When a deposit is credited or a withdrawal changes state, we `POST` an event to your webhook URL. ## Event format ```json { "event_id": "evt_1987654321098754", "type": "deposit.confirmed", "created_at": "1762650000000", "data": { "deposit_id": "1987654321098752", "status": "CONFIRMED", "amount": "12.5", "...": "..." } } ``` `data` is **identical** to the response of the matching GET endpoint, as a snapshot at event time: | type | When | data equals | |---|---|---| | `deposit.confirmed` | The deposit reached the required confirmations and was credited | `GET /v1/deposits/{deposit_id}` | | `withdraw.broadcasted` | The withdrawal transaction was broadcast and awaits confirmation | `GET /v1/withdraws/{withdraw_id}` | | `withdraw.confirmed` | The withdrawal is confirmed (final); in-pool transfers go straight here | same | | `withdraw.failed` | The withdrawal failed (final); see `data.reason`; for pooled merchants the amount frozen at creation is released | same | | `withdraw.rejected` | The withdrawal was rejected in review (final); see `data.reason`; for pooled merchants the amount frozen at creation is released | same | New event types may be added — **return 2xx and ignore types you don't recognise**. ## Headers and signature | Header | Description | |---|---| | `X-Webhook-Id` | Equals `event_id` | | `X-Timestamp` | Time of this delivery attempt, Unix milliseconds (changes on every retry) | | `X-Signature` | `hex(HMAC-SHA256(webhook secret, X-Timestamp + "\n" + body))` | Verify against the **raw body bytes you received** — don't parse and re-serialize the JSON first. Also reject timestamps more than 5 minutes from your clock to prevent replays. Test vector: webhook secret `whsec-test`, `X-Timestamp: 1762650000000`, body ```json {"event_id":"evt_1","type":"deposit.confirmed","created_at":"1762650000000","data":{}} ``` Expected signature: `31c194e933a5f62c752bace322b571f8e281edf589c97c5b672ac245e8a54664`. See [Code examples](./examples) for implementations. ## Acknowledgement, retries and deduplication * Any **2xx** response counts as success; the body is ignored. Each attempt times out after 30 seconds. * On failure (non-2xx, timeout, connection error) each round retries immediately up to 3 times, then backs off until the next round — 1 s, 3 s, 10 s, 30 s, 2 min, 5 min, 10 min, 30 min, 2 h, 6 h, then every 12 h. After 15 rounds (about 3 days) the event is marked `FAILED` and delivery stops. * **The same event can arrive more than once** (retries, manual resends). Dedupe by `event_id` and keep your handler idempotent. * **Events can arrive out of order.** Trust `data.status`; query the GET endpoint for the latest state when needed. * Return 2xx quickly and do slow work asynchronously. ## Recovering missed events * `GET /v1/events?delivery_status=FAILED` lists undelivered events — process their `data`; * `POST /v1/events/{event_id}/resend` redelivers immediately to your current webhook URL (same `event_id`); * if a deposit has neither a webhook nor a record (e.g. the chain feed missed it), rescan it by transaction hash with `POST /v1/deposits/rescan`. --- --- url: /en/reference/withdraws.md --- # Withdrawals Withdrawals: create and query Status: REQUESTING (pending review) → PENDING (approved, signing / broadcasting) → BROADCASTED (broadcast, confirming) → CONFIRMED; other final states are REJECTED (review rejected) and FAILED. Each transition into BROADCASTED / CONFIRMED / FAILED / REJECTED emits an event (withdraw.broadcasted / confirmed / failed / rejected) whose data equals GET /v1/withdraws/{withdraw_id}. ## List withdrawals ```http GET /v1/withdraws ``` API key scope: `read` List withdrawals: ordered by withdraw_id descending, cursor pagination; look up your own order number with biz_id **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `user_id` | query | string | no | User ID: optional | | `biz_id` | query | string | no | Merchant order number: optional | | `chain_code` | query | string | no | Chain code: optional | | `token_code` | query | string | no | Token code: optional | | `status` | query | string | no | Status: optional | | `tx_hash` | query | string | no | Transaction hash: optional | | `start_time` | query | string | no | Start time: by creation time, Unix milliseconds, inclusive | | `end_time` | query | string | no | End time: by creation time, Unix milliseconds, inclusive | | `page_size` | query | integer | no | Page size: default 20, max 100 (larger values are treated as 100) | | `page_token` | query | string | no | Page token: the next_page_token from the previous page; omit for the first page. Opaque — pass it back unchanged | **Response** (200, `WithdrawPage`) | Field | Type | Description | |---|---|---| | `next_page_token` | string | page_token for the next page; empty means no more pages | | `withdraws` | object (Withdraw)\[] | | | `withdraws[].amount` | string | Amount (main unit) actually sent on-chain | | `withdraws[].amount_raw` | string | Amount (integer, smallest unit) | | `withdraws[].biz_id` | string | Merchant order number | | `withdraws[].block_number` | string | | | `withdraws[].broadcast_time` | string | Broadcast time (Unix milliseconds); 0 if not broadcast | | `withdraws[].chain_code` | string | | | `withdraws[].confirm_time` | string | Confirmation time (Unix milliseconds); 0 if not confirmed | | `withdraws[].confirmations` | integer | | | `withdraws[].create_time` | string | Creation time (Unix milliseconds) | | `withdraws[].decimals` | integer | | | `withdraws[].fee` | string | Fee (main unit), charged off-chain | | `withdraws[].from_address` | string | Outbound hot wallet address: set after approval; empty for in-pool transfers | | `withdraws[].internal` | boolean | Whether it is an in-pool transfer (both sides are pooled merchants; not on-chain, instant) | | `withdraws[].reason` | string | Failure or rejection reason (set when FAILED / REJECTED) | | `withdraws[].status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `withdraws[].to_address` | string | | | `withdraws[].token_code` | string | | | `withdraws[].tx_hash` | string | On-chain transaction hash: set after signing; empty for in-pool transfers | | `withdraws[].user_id` | string | | | `withdraws[].withdraw_id` | string | | Response example: ```json { "next_page_token": "", "withdraws": [ { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ] } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Create withdrawal ```http POST /v1/withdraws ``` API key scope: `withdraw` Create withdrawal: withdrawals to external addresses go to manual review; between two pooled merchants it is an in-pool transfer (instant, not on-chain) **Request body** | Field | Type | Description | |---|---|---| | `amount` | string | Amount in the main unit, e.g. 12.5 | | `biz_id` | string | Business order number: the merchant-side idempotency key, unique per merchant | | `chain_code` | string | Chain code, e.g. TRON / ETH / BSC | | `force_onchain` | boolean | Force on-chain: when the destination is another address in the platform pool, the default is an in-pool transfer (instant, no gas, no fee); true sends it on-chain as a normal withdrawal | | `to_address` | string | Destination address | | `token_code` | string | Token, e.g. USDT | | `user_id` | string | User ID: the merchant's own user identifier | Request example: ```json { "amount": "12.5", "biz_id": "order-20261109-0001", "chain_code": "TRON", "force_onchain": false, "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "user_id": "u_10001" } ``` **Response** (200, `Withdraw`) | Field | Type | Description | |---|---|---| | `amount` | string | Amount (main unit) actually sent on-chain | | `amount_raw` | string | Amount (integer, smallest unit) | | `biz_id` | string | Merchant order number | | `block_number` | string | | | `broadcast_time` | string | Broadcast time (Unix milliseconds); 0 if not broadcast | | `chain_code` | string | | | `confirm_time` | string | Confirmation time (Unix milliseconds); 0 if not confirmed | | `confirmations` | integer | | | `create_time` | string | Creation time (Unix milliseconds) | | `decimals` | integer | | | `fee` | string | Fee (main unit), charged off-chain | | `from_address` | string | Outbound hot wallet address: set after approval; empty for in-pool transfers | | `internal` | boolean | Whether it is an in-pool transfer (both sides are pooled merchants; not on-chain, instant) | | `reason` | string | Failure or rejection reason (set when FAILED / REJECTED) | | `status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | On-chain transaction hash: set after signing; empty for in-pool transfers | | `user_id` | string | | | `withdraw_id` | string | | Response example: ```json { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). ## Get withdrawal ```http GET /v1/withdraws/{withdraw_id} ``` API key scope: `read` Get withdrawal **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `withdraw_id` | path | string | yes | Withdrawal ID | **Response** (200, `Withdraw`) | Field | Type | Description | |---|---|---| | `amount` | string | Amount (main unit) actually sent on-chain | | `amount_raw` | string | Amount (integer, smallest unit) | | `biz_id` | string | Merchant order number | | `block_number` | string | | | `broadcast_time` | string | Broadcast time (Unix milliseconds); 0 if not broadcast | | `chain_code` | string | | | `confirm_time` | string | Confirmation time (Unix milliseconds); 0 if not confirmed | | `confirmations` | integer | | | `create_time` | string | Creation time (Unix milliseconds) | | `decimals` | integer | | | `fee` | string | Fee (main unit), charged off-chain | | `from_address` | string | Outbound hot wallet address: set after approval; empty for in-pool transfers | | `internal` | boolean | Whether it is an in-pool transfer (both sides are pooled merchants; not on-chain, instant) | | `reason` | string | Failure or rejection reason (set when FAILED / REJECTED) | | `status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | On-chain transaction hash: set after signing; empty for in-pool transfers | | `user_id` | string | | | `withdraw_id` | string | | Response example: ```json { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ``` On failure the unified error body is returned; see [Errors](/en/guide/errors). --- --- url: /reference/events.md --- # 事件(回调) 回调事件:补拉与重发 漏收回调时用。事件类型:deposit.confirmed;withdraw.broadcasted / confirmed / failed / rejected ## 事件列表 ```http GET /v1/events ``` API key 权限范围: `read` 事件列表:按事件倒序,游标分页;可按投递状态找出没送达的(如 FAILED) **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `type` | 查询 | string | 否 | 事件类型:可选 | | `delivery_status` | 查询 | string | 否 | 投递状态:可选 | | `start_time` | 查询 | string | 否 | 开始时间:按事件时间,Unix 毫秒,含 | | `end_time` | 查询 | string | 否 | 结束时间:按事件时间,Unix 毫秒,含 | | `page_size` | 查询 | integer | 否 | 每页条数:默认 20,最大 100(超过按 100) | | `page_token` | 查询 | string | 否 | 分页令牌:上一页返回的 next_page_token,第一页不传;不透明,原样回传 | **响应** (200, `EventPage`) | 字段 | 类型 | 说明 | |---|---|---| | `events` | object (Event)\[] | | | `events[].created_at` | string | 事件时间(Unix 毫秒) | | `events[].data` | object | 事件数据:与对应 GET 接口返回的资源完全一致(deposit.confirmed → GET /v1/deposits/{deposit_id}),为事件发生时的快照 | | `events[].delivery_status` | string | 投递状态:PENDING(待投递)/ RETRY(失败等待重试)/ SUCCESS(商户已返回 2xx)/ FAILED(重试用尽) | | `events[].event_id` | string | 事件ID(回调请求头 X-Webhook-Id 与 body 里的 event_id) | | `events[].last_http_status` | integer | 最后一次投递的 HTTP 状态码,没有为 0 | | `events[].retry_count` | integer | 失败重试轮次(每轮最多即时重试 3 次,之后按退避等待下一轮;超过上限标记 FAILED) | | `events[].type` | string | 事件类型:deposit.confirmed / withdraw.broadcasted / withdraw.confirmed / withdraw.failed / withdraw.rejected | | `next_page_token` | string | 下一页的 page_token;为空表示没有更多 | 响应示例: ```json { "events": [ { "created_at": "1762650000000", "data": {}, "delivery_status": "PENDING", "event_id": "evt_1987654321098754", "last_http_status": 0, "retry_count": 0, "type": "deposit.confirmed" } ], "next_page_token": "" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 重发事件 ```http POST /v1/events/{event_id}/resend ``` API key 权限范围: `read` 重发事件:立即按商户当前的回调地址与回调密钥重新投递一次(event_id 不变,商户按它去重) **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `event_id` | 路径 | string | 是 | 事件ID:evt\_ 开头 | **请求体** | 字段 | 类型 | 说明 | |---|---|---| | `event_id` | string | 事件ID:evt\_ 开头 | 请求示例: ```json { "event_id": "evt_1987654321098754" } ``` **响应** (200, `Event`) | 字段 | 类型 | 说明 | |---|---|---| | `created_at` | string | 事件时间(Unix 毫秒) | | `data` | object | 事件数据:与对应 GET 接口返回的资源完全一致(deposit.confirmed → GET /v1/deposits/{deposit_id}),为事件发生时的快照 | | `delivery_status` | string | 投递状态:PENDING(待投递)/ RETRY(失败等待重试)/ SUCCESS(商户已返回 2xx)/ FAILED(重试用尽) | | `event_id` | string | 事件ID(回调请求头 X-Webhook-Id 与 body 里的 event_id) | | `last_http_status` | integer | 最后一次投递的 HTTP 状态码,没有为 0 | | `retry_count` | integer | 失败重试轮次(每轮最多即时重试 3 次,之后按退避等待下一轮;超过上限标记 FAILED) | | `type` | string | 事件类型:deposit.confirmed / withdraw.broadcasted / withdraw.confirmed / withdraw.failed / withdraw.rejected | 响应示例: ```json { "created_at": "1762650000000", "data": {}, "delivery_status": "PENDING", "event_id": "evt_1987654321098754", "last_http_status": 0, "retry_count": 0, "type": "deposit.confirmed" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 --- --- url: /reference/deposits.md --- # 充值 充值记录与补单 ## 充值记录 ```http GET /v1/deposits ``` API key 权限范围: `read` 充值记录:按 deposit_id 倒序,游标分页 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `user_id` | 查询 | string | 否 | 用户ID:可选 | | `chain_code` | 查询 | string | 否 | 链代码:可选 | | `token_code` | 查询 | string | 否 | 币种代码:可选 | | `status` | 查询 | string | 否 | 状态:可选 | | `tx_hash` | 查询 | string | 否 | 交易哈希:可选 | | `start_time` | 查询 | string | 否 | 开始时间:按发现时间,Unix 毫秒,含 | | `end_time` | 查询 | string | 否 | 结束时间:按发现时间,Unix 毫秒,含 | | `page_size` | 查询 | integer | 否 | 每页条数:默认 20,最大 100(超过按 100) | | `page_token` | 查询 | string | 否 | 分页令牌:上一页返回的 next_page_token,第一页不传;不透明,原样回传 | **响应** (200, `DepositPage`) | 字段 | 类型 | 说明 | |---|---|---| | `deposits` | object (Deposit)\[] | | | `deposits[].amount` | string | 金额(主单位) | | `deposits[].amount_raw` | string | 金额(最小单位整数) | | `deposits[].block_number` | string | | | `deposits[].chain_code` | string | | | `deposits[].confirm_time` | string | 确认入账时间(Unix 毫秒),未确认为 0 | | `deposits[].confirmations` | integer | | | `deposits[].create_time` | string | 发现时间(Unix 毫秒) | | `deposits[].decimals` | integer | | | `deposits[].deposit_id` | string | | | `deposits[].from_address` | string | 付款地址;内部转账为平台出账热钱包地址 | | `deposits[].internal` | boolean | 是否平台内部转账(付款方是平台内其它用户,不上链、即时到账) | | `deposits[].log_index` | integer | 交易内的事件序号(同一交易多笔转账时区分) | | `deposits[].status` | string | PENDING(确认中,不可入账)/ CONFIRMED(已入账)/ FAILED / REORGED / BELOW_MIN(低于最小充值额,只记录不入账) | | `deposits[].to_address` | string | | | `deposits[].token_code` | string | | | `deposits[].tx_hash` | string | 链上交易哈希;内部转账为空 | | `deposits[].user_id` | string | | | `next_page_token` | string | 下一页的 page_token;为空表示没有更多 | 响应示例: ```json { "deposits": [ { "amount": "12.5", "amount_raw": "12500000", "block_number": "77123456", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "deposit_id": "1987654321098752", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "log_index": 0, "status": "PENDING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ], "next_page_token": "" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 单笔充值 ```http GET /v1/deposits/{deposit_id} ``` API key 权限范围: `read` 单笔充值 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `deposit_id` | 路径 | string | 是 | 充值ID | **响应** (200, `Deposit`) | 字段 | 类型 | 说明 | |---|---|---| | `amount` | string | 金额(主单位) | | `amount_raw` | string | 金额(最小单位整数) | | `block_number` | string | | | `chain_code` | string | | | `confirm_time` | string | 确认入账时间(Unix 毫秒),未确认为 0 | | `confirmations` | integer | | | `create_time` | string | 发现时间(Unix 毫秒) | | `decimals` | integer | | | `deposit_id` | string | | | `from_address` | string | 付款地址;内部转账为平台出账热钱包地址 | | `internal` | boolean | 是否平台内部转账(付款方是平台内其它用户,不上链、即时到账) | | `log_index` | integer | 交易内的事件序号(同一交易多笔转账时区分) | | `status` | string | PENDING(确认中,不可入账)/ CONFIRMED(已入账)/ FAILED / REORGED / BELOW_MIN(低于最小充值额,只记录不入账) | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | 链上交易哈希;内部转账为空 | | `user_id` | string | | 响应示例: ```json { "amount": "12.5", "amount_raw": "12500000", "block_number": "77123456", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "deposit_id": "1987654321098752", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "log_index": 0, "status": "PENDING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 按交易哈希补单 ```http POST /v1/deposits/rescan ``` API key 权限范围: `deposit` 按交易哈希补单:重新识别该交易里转给该用户的充值(漏推送时用);已识别的不会重复入账 **请求体** | 字段 | 类型 | 说明 | |---|---|---| | `chain_code` | string | 链代码 | | `tx_hash` | string | 交易哈希 | | `user_id` | string | 用户ID:商户侧用户标识 | 请求示例: ```json { "chain_code": "TRON", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001" } ``` **响应** (200, `RescanResult`) | 字段 | 类型 | 说明 | |---|---|---| | `chain_code` | string | | | `items` | object (RescanResultItem)\[] | | | `items[].amount` | string | 金额(主单位) | | `items[].deposit_id` | string | 充值ID;为空表示该笔转账被跳过(如原生币、币种未登记) | | `items[].log_index` | integer | | | `items[].status` | string | 充值状态;被跳过为 SKIPPED | | `items[].to_address` | string | | | `message` | string | | | `result` | string | ACCEPTED:至少一笔充值已记录、等待确认入账;SKIPPED:没有可入账的充值 | | `tx_hash` | string | | 响应示例: ```json { "chain_code": "TRON", "items": [ { "amount": "12.5", "deposit_id": "1987654321098752", "log_index": 0, "status": "SKIPPED", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" } ], "message": "amount below minimum", "result": "", "tx_hash": "3f1c0e…(交易哈希)" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 --- --- url: /guide/webhooks.md --- # 回调(事件) 充值入账、提现状态变化时,我们向你配置的回调地址 `POST` 一条事件。 ## 事件格式 ```json { "event_id": "evt_1987654321098754", "type": "deposit.confirmed", "created_at": "1762650000000", "data": { "deposit_id": "1987654321098752", "status": "CONFIRMED", "amount": "12.5", "...": "..." } } ``` `data` 与对应 GET 接口的返回**完全一致**,是事件发生时的快照: | type | 触发时机 | data 等同于 | |---|---|---| | `deposit.confirmed` | 充值达到确认数、已入账 | `GET /v1/deposits/{deposit_id}` | | `withdraw.broadcasted` | 提现交易已广播,等待链上确认 | `GET /v1/withdraws/{withdraw_id}` | | `withdraw.confirmed` | 提现已确认(终态);池内转账直接进入此状态 | 同上 | | `withdraw.failed` | 提现失败(终态),原因见 `data.reason`;资金池商户创建时冻结的金额已解冻 | 同上 | | `withdraw.rejected` | 提现审核被拒(终态),原因见 `data.reason`;资金池商户创建时冻结的金额已解冻 | 同上 | 以后可能新增事件类型,**不认识的 type 请直接返回 2xx 忽略**。 ## 请求头与验签 | 请求头 | 说明 | |---|---| | `X-Webhook-Id` | 等于 `event_id` | | `X-Timestamp` | 本次投递时间,Unix 毫秒(每次重试都会变) | | `X-Signature` | `hex(HMAC-SHA256(回调密钥, X-Timestamp + "\n" + body))` | 验签时必须用**收到的原始 body 字节**,不要先解析 JSON 再序列化。建议同时拒绝时间戳与本机相差超过 5 分钟的请求,防止重放。 测试数据:回调密钥 `whsec-test`,`X-Timestamp: 1762650000000`,body 为 ```json {"event_id":"evt_1","type":"deposit.confirmed","created_at":"1762650000000","data":{}} ``` 签名应为 `31c194e933a5f62c752bace322b571f8e281edf589c97c5b672ac245e8a54664`。各语言实现见 [多语言示例](./examples)。 ## 应答、重试与去重 * 返回任意 **2xx** 视为成功,响应体不限。单次请求超时 30 秒。 * 失败(非 2xx、超时、连不上)时:每轮先即时重试最多 3 次,仍失败则按退避等待下一轮——1 秒、3 秒、10 秒、30 秒、2 分钟、5 分钟、10 分钟、30 分钟、2 小时、6 小时,之后每 12 小时一轮;共 15 轮(约 3 天)仍失败则标记为 `FAILED` 并停止。 * **同一事件可能送达多次**(重试、手动重发),请按 `event_id` 去重,处理逻辑要幂等。 * **事件可能乱序**。以 `data.status` 为准;需要时用 GET 接口查最新状态。 * 请尽快返回 2xx,耗时的业务放到异步队列里处理。 ## 漏收补救 * `GET /v1/events?delivery_status=FAILED` 找出没送达的事件,按 `data` 补处理; * `POST /v1/events/{event_id}/resend` 让我们立即按当前回调地址重新投递一次(`event_id` 不变); * 充值没有回调也查不到记录时(例如链上推送遗漏),可用 `POST /v1/deposits/rescan` 按交易哈希补单。 --- --- 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> params) { List 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> 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 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] 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] 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`。 --- --- url: /guide/quickstart.md --- # 快速开始 ## 1. 拿到凭证 开通商户后,平台会提供: | 凭证 | 用途 | |---|---| | API key + API secret | 调用接口时签名。secret 只显示一次,请妥善保存;可按权限范围(`read` / `deposit` / `withdraw`)分别创建,并绑定 IP 白名单 | | 回调地址 + 回调密钥 | 我们向你的地址推送事件时,用回调密钥签名,你用它验签 | Base URL:`https://api.dtpg.net` ## 2. 发第一个请求 查询支持的链。每个请求都要带 4 个签名请求头,规则见 [签名认证](./authentication);各语言的现成代码见 [多语言示例](./examples)。 ```bash # 1. 你的凭证 API_KEY="你的 API key" API_SECRET="你的 API secret" # 2. 本次请求 METHOD="GET" URI_PATH="/v1/chains" QUERY="" # 查询串(规范化后的),没有就留空 BODY="" # 请求体,GET 留空 TIMESTAMP=$(( $(date +%s) * 1000 )) # 当前时间,Unix 毫秒 NONCE=$(openssl rand -hex 16) # 32 位随机串 # 3. 待签名串:6 段用换行连接 BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}') STRING_TO_SIGN=$(printf '%s\n%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$URI_PATH" "$QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH") # 4. 签名 = HMAC-SHA256(API secret, 待签名串),hex SIGNATURE=$(printf '%s' "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}') # 5. 发请求 curl "https://api.dtpg.net${URI_PATH}" \ -H "X-Api-Key: $API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Nonce: $NONCE" \ -H "X-Signature: $SIGNATURE" ``` ```json { "chains": [{ "code": "TRON", "name": "TRON" }] } ``` ## 3. 典型接入流程 1. **给用户分配充值地址**:`POST /v1/wallets/{user_id}/addresses`(幂等,可以每次展示前调用)。 2. **等待充值回调** `deposit.confirmed`:按 `event_id` 去重后给用户加余额。漏收时用 `GET /v1/events` 补拉。 3. **发起提现**:`POST /v1/withdraws`,`biz_id` 用你自己的订单号(幂等键)。 4. **跟踪提现**:回调 `withdraw.broadcasted` → `withdraw.confirmed`(或 `withdraw.failed` / `withdraw.rejected`),也可 `GET /v1/withdraws/{withdraw_id}` 主动查。 ::: tip 给 AI / 代码助手 把 [`/llms-full.txt`](/llms-full.txt) 或 [`/openapi.yaml`](/openapi.yaml) 交给你的 AI 助手,它就能按本文档生成接入代码。每页标题下也有「复制为 Markdown」按钮。 ::: --- --- url: /reference.md --- # 接口一览 Base URL:`https://api.dtpg.net`。所有接口都要签名,见 [签名认证](/guide/authentication)。 机器可读的 OpenAPI 3 定义:[openapi.yaml](/openapi.yaml)(可导入 Postman / Apifox / 代码生成器)。 | 方法 | Path | 说明 | Scope | |---|---|---|---| | `POST` | [`/v1/addresses/validate`](/reference/catalog#地址校验) | 地址校验 | `read` | | `GET` | [`/v1/assets`](/reference/catalog#支持的资产) | 支持的资产 | `read` | | `GET` | [`/v1/chains`](/reference/catalog#支持的链) | 支持的链 | `read` | | `GET` | [`/v1/wallets/{user_id}/addresses`](/reference/wallets#用户的全部充值地址) | 用户的全部充值地址 | `read` | | `POST` | [`/v1/wallets/{user_id}/addresses`](/reference/wallets#取或建充值地址) | 取或建充值地址 | `deposit` | | `GET` | [`/v1/wallets/{user_id}/balances`](/reference/wallets#用户余额) | 用户余额 | `read` | | `GET` | [`/v1/deposits`](/reference/deposits#充值记录) | 充值记录 | `read` | | `GET` | [`/v1/deposits/{deposit_id}`](/reference/deposits#单笔充值) | 单笔充值 | `read` | | `POST` | [`/v1/deposits/rescan`](/reference/deposits#按交易哈希补单) | 按交易哈希补单 | `deposit` | | `GET` | [`/v1/withdraws`](/reference/withdraws#提现记录) | 提现记录 | `read` | | `POST` | [`/v1/withdraws`](/reference/withdraws#发起提现) | 发起提现 | `withdraw` | | `GET` | [`/v1/withdraws/{withdraw_id}`](/reference/withdraws#单笔提现) | 单笔提现 | `read` | | `GET` | [`/v1/events`](/reference/events#事件列表) | 事件列表 | `read` | | `POST` | [`/v1/events/{event_id}/resend`](/reference/events#重发事件) | 重发事件 | `read` | --- --- url: /reference/withdraws.md --- # 提现 提现:发起、查询 状态:REQUESTING(待审核)→ PENDING(已审核,签名 / 广播中)→ BROADCASTED(已广播,确认中)→ CONFIRMED; 终态还有 REJECTED(审核拒绝)、FAILED(失败)。每次进入 BROADCASTED / CONFIRMED / FAILED / REJECTED 发一条事件 (withdraw.broadcasted / confirmed / failed / rejected),data 与 GET /v1/withdraws/{withdraw_id} 相同 ## 提现记录 ```http GET /v1/withdraws ``` API key 权限范围: `read` 提现记录:按 withdraw_id 倒序,游标分页;按 biz_id 查商户自己的单号 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `user_id` | 查询 | string | 否 | 用户ID:可选 | | `biz_id` | 查询 | string | 否 | 商户单号:可选 | | `chain_code` | 查询 | string | 否 | 链代码:可选 | | `token_code` | 查询 | string | 否 | 币种代码:可选 | | `status` | 查询 | string | 否 | 状态:可选 | | `tx_hash` | 查询 | string | 否 | 交易哈希:可选 | | `start_time` | 查询 | string | 否 | 开始时间:按创建时间,Unix 毫秒,含 | | `end_time` | 查询 | string | 否 | 结束时间:按创建时间,Unix 毫秒,含 | | `page_size` | 查询 | integer | 否 | 每页条数:默认 20,最大 100(超过按 100) | | `page_token` | 查询 | string | 否 | 分页令牌:上一页返回的 next_page_token,第一页不传;不透明,原样回传 | **响应** (200, `WithdrawPage`) | 字段 | 类型 | 说明 | |---|---|---| | `next_page_token` | string | 下一页的 page_token;为空表示没有更多 | | `withdraws` | object (Withdraw)\[] | | | `withdraws[].amount` | string | 金额(主单位),链上实际转出的金额 | | `withdraws[].amount_raw` | string | 金额(最小单位整数) | | `withdraws[].biz_id` | string | 商户单号 | | `withdraws[].block_number` | string | | | `withdraws[].broadcast_time` | string | 广播时间(Unix 毫秒),未广播为 0 | | `withdraws[].chain_code` | string | | | `withdraws[].confirm_time` | string | 确认时间(Unix 毫秒),未确认为 0 | | `withdraws[].confirmations` | integer | | | `withdraws[].create_time` | string | 创建时间(Unix 毫秒) | | `withdraws[].decimals` | integer | | | `withdraws[].fee` | string | 手续费(主单位),不上链 | | `withdraws[].from_address` | string | 出账热钱包地址:审核通过后才有;池内转账为空 | | `withdraws[].internal` | boolean | 是否池内转账(双方都是资金池商户,不上链、即时完成) | | `withdraws[].reason` | string | 失败或拒绝原因(FAILED / REJECTED 时有值) | | `withdraws[].status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `withdraws[].to_address` | string | | | `withdraws[].token_code` | string | | | `withdraws[].tx_hash` | string | 链上交易哈希:签名后才有;池内转账为空 | | `withdraws[].user_id` | string | | | `withdraws[].withdraw_id` | string | | 响应示例: ```json { "next_page_token": "", "withdraws": [ { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ] } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 发起提现 ```http POST /v1/withdraws ``` API key 权限范围: `withdraw` 发起提现:外部地址进入人工审核;双方都是资金池商户时池内转账(即时完成、不上链) **请求体** | 字段 | 类型 | 说明 | |---|---|---| | `amount` | string | 金额:主单位,如 12.5 | | `biz_id` | string | 业务单号:商户侧幂等键,同一商户内唯一 | | `chain_code` | string | 链代码:如 TRON / ETH / BSC | | `force_onchain` | boolean | 强制上链:目标是平台资金池内另一个地址时,默认池内转账(即时、不耗 gas、不收手续费);true 则按普通提现上链 | | `to_address` | string | 目标地址 | | `token_code` | string | 币种:如 USDT | | `user_id` | string | 用户ID:商户侧用户标识 | 请求示例: ```json { "amount": "12.5", "biz_id": "order-20261109-0001", "chain_code": "TRON", "force_onchain": false, "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "user_id": "u_10001" } ``` **响应** (200, `Withdraw`) | 字段 | 类型 | 说明 | |---|---|---| | `amount` | string | 金额(主单位),链上实际转出的金额 | | `amount_raw` | string | 金额(最小单位整数) | | `biz_id` | string | 商户单号 | | `block_number` | string | | | `broadcast_time` | string | 广播时间(Unix 毫秒),未广播为 0 | | `chain_code` | string | | | `confirm_time` | string | 确认时间(Unix 毫秒),未确认为 0 | | `confirmations` | integer | | | `create_time` | string | 创建时间(Unix 毫秒) | | `decimals` | integer | | | `fee` | string | 手续费(主单位),不上链 | | `from_address` | string | 出账热钱包地址:审核通过后才有;池内转账为空 | | `internal` | boolean | 是否池内转账(双方都是资金池商户,不上链、即时完成) | | `reason` | string | 失败或拒绝原因(FAILED / REJECTED 时有值) | | `status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | 链上交易哈希:签名后才有;池内转账为空 | | `user_id` | string | | | `withdraw_id` | string | | 响应示例: ```json { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 单笔提现 ```http GET /v1/withdraws/{withdraw_id} ``` API key 权限范围: `read` 单笔提现 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `withdraw_id` | 路径 | string | 是 | 提现ID | **响应** (200, `Withdraw`) | 字段 | 类型 | 说明 | |---|---|---| | `amount` | string | 金额(主单位),链上实际转出的金额 | | `amount_raw` | string | 金额(最小单位整数) | | `biz_id` | string | 商户单号 | | `block_number` | string | | | `broadcast_time` | string | 广播时间(Unix 毫秒),未广播为 0 | | `chain_code` | string | | | `confirm_time` | string | 确认时间(Unix 毫秒),未确认为 0 | | `confirmations` | integer | | | `create_time` | string | 创建时间(Unix 毫秒) | | `decimals` | integer | | | `fee` | string | 手续费(主单位),不上链 | | `from_address` | string | 出账热钱包地址:审核通过后才有;池内转账为空 | | `internal` | boolean | 是否池内转账(双方都是资金池商户,不上链、即时完成) | | `reason` | string | 失败或拒绝原因(FAILED / REJECTED 时有值) | | `status` | string | REQUESTING / PENDING / BROADCASTED / CONFIRMED / REJECTED / FAILED | | `to_address` | string | | | `token_code` | string | | | `tx_hash` | string | 链上交易哈希:签名后才有;池内转账为空 | | `user_id` | string | | | `withdraw_id` | string | | 响应示例: ```json { "amount": "12.5", "amount_raw": "12500000", "biz_id": "order-20261109-0001", "block_number": "77123456", "broadcast_time": "1762650000000", "chain_code": "TRON", "confirm_time": "1762650000000", "confirmations": 0, "create_time": "1762650000000", "decimals": 6, "fee": "", "from_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", "internal": false, "reason": "AMOUNT_BELOW_MIN", "status": "REQUESTING", "to_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "token_code": "USDT", "tx_hash": "3f1c0e…(交易哈希)", "user_id": "u_10001", "withdraw_id": "1987654321098753" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 --- --- url: /reference/wallets.md --- # 用户钱包 用户钱包:充值地址、余额 ## 用户的全部充值地址 ```http GET /v1/wallets/{user_id}/addresses ``` API key 权限范围: `read` 用户的全部充值地址;用户不存在返回空列表 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `user_id` | 路径 | string | 是 | 用户ID:商户侧用户标识 | **响应** (200, `AddressList`) | 字段 | 类型 | 说明 | |---|---|---| | `addresses` | object (Address)\[] | | | `addresses[].address` | string | | | `addresses[].chain_code` | string | | | `addresses[].create_time` | string | 创建时间(Unix 毫秒) | | `addresses[].status` | string | ACTIVE | | `addresses[].user_id` | string | | 响应示例: ```json { "addresses": [ { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON", "create_time": "1762650000000", "status": "ACTIVE", "user_id": "u_10001" } ] } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 取或建充值地址 ```http POST /v1/wallets/{user_id}/addresses ``` API key 权限范围: `deposit` 取或建充值地址:用户钱包不存在则创建,该链地址不存在则派生;幂等,重复调用返回同一地址 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `user_id` | 路径 | string | 是 | 用户ID:商户侧用户标识 | **请求体** | 字段 | 类型 | 说明 | |---|---|---| | `chain_code` | string | 链代码 | | `master_key_id` | string | 主密钥ID:可选,仅在用户钱包首次创建时生效,不传用商户当前 ACTIVE 主密钥 | | `user_id` | string | 用户ID:商户侧用户标识 | 请求示例: ```json { "chain_code": "TRON", "master_key_id": "", "user_id": "u_10001" } ``` **响应** (200, `Address`) | 字段 | 类型 | 说明 | |---|---|---| | `address` | string | | | `chain_code` | string | | | `create_time` | string | 创建时间(Unix 毫秒) | | `status` | string | ACTIVE | | `user_id` | string | | 响应示例: ```json { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON", "create_time": "1762650000000", "status": "ACTIVE", "user_id": "u_10001" } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 用户余额 ```http GET /v1/wallets/{user_id}/balances ``` API key 权限范围: `read` 用户余额:按资产列出可用 / 冻结(只含已入账的确认充值与内部转账);用户不存在返回空列表 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `user_id` | 路径 | string | 是 | 用户ID:商户侧用户标识 | **响应** (200, `BalanceList`) | 字段 | 类型 | 说明 | |---|---|---| | `balances` | object (Balance)\[] | | | `balances[].available` | string | 可用(主单位) | | `balances[].available_raw` | string | 可用(最小单位整数) | | `balances[].chain_code` | string | | | `balances[].decimals` | integer | | | `balances[].frozen` | string | 冻结(主单位) | | `balances[].frozen_raw` | string | 冻结(最小单位整数) | | `balances[].token_code` | string | | | `balances[].update_time` | string | 最后变动时间(Unix 毫秒) | 响应示例: ```json { "balances": [ { "available": "100.5", "available_raw": "100500000", "chain_code": "TRON", "decimals": 6, "frozen": "0", "frozen_raw": "0", "token_code": "USDT", "update_time": "1762650000000" } ] } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 --- --- url: /guide/authentication.md --- # 签名认证 所有 `/v1/**` 接口都用 HMAC-SHA256 签名认证,没有登录态、没有 token。 ## 请求头 | 请求头 | 说明 | |---|---| | `X-Api-Key` | API key | | `X-Timestamp` | 当前时间,Unix **毫秒**。与服务器相差超过 ±30 秒拒绝(`REQUEST_EXPIRED`) | | `X-Nonce` | 16–64 位随机串,同一个 key 5 分钟内不能重复(`NONCE_REPLAYED`)。建议 16 字节随机数的 hex | | `X-Signature` | 签名,见下文,hex 小写 | | `Content-Type` | 有请求体时为 `application/json` | ## 待签名串 用 `\n`(换行,0x0A)把 6 段连起来,**末尾没有换行**: ```text METHOD PATH CANONICAL_QUERY TIMESTAMP NONCE hex(sha256(BODY)) ``` | 段 | 规则 | |---|---| | `METHOD` | 大写,如 `GET`、`POST` | | `PATH` | URL 路径,不含域名和查询串,按实际发送的(已百分号编码的)形式,如 `/v1/wallets/u_10001/addresses` | | `CANONICAL_QUERY` | 查询参数规范化:按 key 排序,同 key 按 value 排序;key 与 value 按 RFC 3986 编码(只保留 `A-Z a-z 0-9 - _ . ~`,空格编码为 `%20` 而不是 `+`);用 `&` 连接。没有查询参数时为空串 | | `TIMESTAMP` | 与 `X-Timestamp` 相同 | | `NONCE` | 与 `X-Nonce` 相同 | | `hex(sha256(BODY))` | 请求体原始字节的 SHA-256,hex 小写。没有请求体(如 GET)时是空串的哈希 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | ```text X-Signature = hex(HMAC-SHA256(API secret, 待签名串)) ``` ::: warning 两个最常见的坑 1. **签的 body 必须就是发出去的字节**。先把 JSON 序列化成字符串,用这个字符串算哈希并作为请求体发送;不要签一份、HTTP 库再序列化一份。 2. **直接把规范化后的查询串作为 URL 的查询部分发送**,就不会出现「签的和发的顺序 / 编码不一致」。 ::: ## 示例 API secret 为 `test-secret`,`X-Timestamp: 1762650000000`,`X-Nonce: 0123456789abcdef0123`: ```http GET /v1/deposits?user_id=u%201&chain_code=TRON&status=PENDING&status=CONFIRMED¬e=a%2Bb%21 ``` 规范化查询串: ```text chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201 ``` 待签名串: ```text GET /v1/deposits chain_code=TRON¬e=a%2Bb%21&status=CONFIRMED&status=PENDING&user_id=u%201 1762650000000 0123456789abcdef0123 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` 签名:`d474bb4c8ff89a37f214d8c39c3d17a502d54b64c1822024a86480c248bcbc86` 带请求体(`POST /v1/withdraws`,同样的时间戳和 nonce): ```json {"user_id":"u1","chain_code":"TRON","token_code":"USDT","to_address":"TXYZ","amount":"12.5","biz_id":"order-1"} ``` 签名:`381b9e20fc9b2e0cd97ecb003686acd472db596757b674e56df9e038d708867c` 用这两组数据可以校验自己的实现。 ## 权限范围与 IP 白名单 每个 API key 有权限范围,接口需要的范围写在 [接口参考](/reference/) 里: | 范围 | 能做什么 | |---|---| | `read` | 所有查询接口、重发事件 | | `deposit` | 创建充值地址、补单 | | `withdraw` | 发起提现 | 缺少范围返回 `PERMISSION_DENIED`;请求来源 IP 不在 key 的白名单里返回 `IP_NOT_ALLOWED`。建议发起提现用单独的 `withdraw` key,并只放行服务器出口 IP。 ## 鉴权错误 | reason | HTTP | 原因 | |---|---|---| | `MERCHANT_AUTH_FAILED` | 401 | 缺少签名请求头、key 不存在或已停用、商户已停用 | | `SIGNATURE_INVALID` | 401 | 签名不对:对照上面的示例逐段检查待签名串 | | `REQUEST_EXPIRED` | 401 | 时间戳缺失、不是毫秒、或与服务器相差超过 30 秒(检查服务器时钟 / NTP) | | `NONCE_REPLAYED` | 401 | nonce 重复,或长度不在 16–64 | | `IP_NOT_ALLOWED` | 403 | 来源 IP 不在白名单 | | `PERMISSION_DENIED` | 403 | key 没有接口需要的权限范围 | --- --- url: /reference/catalog.md --- # 资产目录 资产目录:支持的链、资产,地址校验 ## 地址校验 ```http POST /v1/addresses/validate ``` API key 权限范围: `read` 地址校验:格式是否正确、是否平台内地址(平台内地址提现走内部转账,不上链) **请求体** | 字段 | 类型 | 说明 | |---|---|---| | `address` | string | 地址 | | `chain_code` | string | 链代码 | 请求示例: ```json { "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "chain_code": "TRON" } ``` **响应** (200, `AddressValidation`) | 字段 | 类型 | 说明 | |---|---|---| | `internal` | boolean | 是否平台内的用户充值地址:是则提现到该地址走内部转账(即时到账、不上链、无手续费) | | `normalized_address` | string | 规整后的地址(EVM 为全小写);无效时为空 | | `valid` | boolean | 格式是否正确 | 响应示例: ```json { "internal": false, "normalized_address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "valid": false } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 支持的资产 ```http GET /v1/assets ``` API key 权限范围: `read` 支持的资产:链 × 币种(精度、合约、最小充值 / 提现额、手续费),可按链过滤 **请求参数** | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | `chain_code` | 查询 | string | 否 | 链代码:可选,不传返回所有启用链的资产 | **响应** (200, `AssetList`) | 字段 | 类型 | 说明 | |---|---|---| | `assets` | object (Asset)\[] | | | `assets[].chain_code` | string | | | `assets[].contract_address` | string | 合约地址;原生币为空 | | `assets[].decimals` | integer | | | `assets[].deposit_enabled` | boolean | | | `assets[].explorer_url_template` | string | 区块浏览器交易链接模板,{txHash} 替换为交易哈希 | | `assets[].min_deposit_amount` | string | 以下金额为主单位十进制字符串 | | `assets[].min_withdraw_amount` | string | | | `assets[].token_code` | string | 币种代码,如 USDT | | `assets[].token_name` | string | | | `assets[].withdraw_enabled` | boolean | | | `assets[].withdraw_fee` | string | | 响应示例: ```json { "assets": [ { "chain_code": "TRON", "contract_address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6, "deposit_enabled": false, "explorer_url_template": "https://tronscan.org/#/transaction/{txHash}", "min_deposit_amount": "1", "min_withdraw_amount": "10", "token_code": "USDT", "token_name": "Tether USD", "withdraw_enabled": false, "withdraw_fee": "1" } ] } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 ## 支持的链 ```http GET /v1/chains ``` API key 权限范围: `read` 支持的链:只返回启用的链 **响应** (200, `ChainList`) | 字段 | 类型 | 说明 | |---|---|---| | `chains` | object (Chain)\[] | | | `chains[].code` | string | 链代码:TRON / ETH / BSC | | `chains[].evm_chain_id` | string | EVM chainId(如 "1"、"56"),TRON 为空 | | `chains[].name` | string | | 响应示例: ```json { "chains": [ { "code": "TRON", "evm_chain_id": "", "name": "TRON" } ] } ``` 失败时返回统一错误体,见 [错误处理](/guide/errors)。 --- --- url: /guide/conventions.md --- # 通用约定 ## 请求与响应 * 只用 HTTPS;请求体和响应体都是 JSON,UTF-8。 * 字段名一律 `snake_case`。 * 成功时 HTTP 200,**直接返回资源本身**(没有 `{code, data}` 外层包装)。失败时返回 [错误体](./errors)。 * 值为 `false`、`0`、空串的字段**可能不出现在响应里**,请按默认值处理。 * 以后可能新增字段,请忽略不认识的字段。 ## 数值与时间 | 类型 | 表示 | |---|---| | 时间 | Unix **毫秒**,JSON 字符串,如 `"1762650000000"`;`0` 表示「还没有」 | | int64(ID、区块号等) | JSON **字符串**,避免 JavaScript 精度丢失,如 `"deposit_id": "1987654321098752"` | | 金额 | 同时给两种:`amount` 为主单位十进制字符串(如 `"12.5"`),`amount_raw` 为最小单位整数字符串(如 `"12500000"`,配合 `decimals`)。**不要用浮点数计算金额** | ## 分页 列表接口使用游标分页: | 参数 / 字段 | 说明 | |---|---| | `page_size` | 每页条数,默认 20,最大 100(超过按 100) | | `page_token` | 第一页不传;之后传上一页返回的 `next_page_token` | | `next_page_token` | 不透明字符串,**原样回传**;为空表示没有更多 | 响应形如 `{"deposits": [...], "next_page_token": "..."}`,列表字段名是资源复数。翻页时其它筛选参数要保持不变。 ## 幂等 | 接口 | 幂等方式 | |---|---| | `POST /v1/wallets/{user_id}/addresses` | 天然幂等,同一用户同一条链永远返回同一地址 | | `POST /v1/withdraws` | `biz_id`(你的订单号)在商户内唯一;重复提交返回 `409 DUPLICATE_REQUEST`,`metadata.withdrawId` 为已存在的提现单 | | `POST /v1/deposits/rescan` | 已识别的充值不会重复入账 | 网络超时等无法确定结果时,用同一个 `biz_id` 重试,或用 `GET /v1/withdraws?biz_id=` 查询,**不要换新单号重试**。 ## 链与币种代码 链代码:`TRON`、`ETH`、`BSC`(以 `GET /v1/chains` 返回的为准)。币种代码如 `USDT`;某个币种在某条链上是否可充可提、最小额与手续费以 `GET /v1/assets` 为准。 --- --- url: /guide/errors.md --- # 错误处理 失败时返回非 200 状态码和统一的错误体: ```json { "code": 400, "reason": "AMOUNT_BELOW_MIN", "message": "amount below minimum", "metadata": { "minAmount": "10" } } ``` | 字段 | 说明 | |---|---| | `code` | HTTP 状态码 | | `reason` | 稳定的错误标识,**请按它分支处理** | | `message` | 英文说明,只给开发者看,可能变化,不要解析 | | `metadata` | 动态参数(字符串键值),见下表 | ## reason 一览 | reason | HTTP | 说明 | metadata | |---|---|---|---| | `BAD_REQUEST` | 400 | 参数不合法(缺字段、格式错误等) | | | `VALIDATOR` | 400 | 参数校验失败,`message` 指出哪个字段 | | | `TOO_MANY_REQUESTS` | 429 | 请求过于频繁,稍后重试 | | | `NOT_FOUND` | 404 | 资源不存在 | | | `MERCHANT_AUTH_FAILED` | 401 | API key 无效 / 停用、商户停用、缺少签名请求头 | | | `SIGNATURE_INVALID` | 401 | 签名不匹配 | | | `REQUEST_EXPIRED` | 401 | `X-Timestamp` 缺失或超出 ±30 秒 | | | `NONCE_REPLAYED` | 401 | `X-Nonce` 重复或长度不对 | | | `IP_NOT_ALLOWED` | 403 | 来源 IP 不在白名单 | | | `PERMISSION_DENIED` | 403 | API key 没有该接口需要的权限范围 | | | `CHAIN_NOT_SUPPORTED` | 400 | 链不存在或未启用 | | | `TOKEN_NOT_SUPPORTED` | 400 | 币种不存在或该链未配置 | | | `ADDRESS_INVALID` | 400 | 地址格式不正确 | | | `USER_NOT_FOUND` | 404 | 用户钱包不存在(还没获取过地址) | | | `INSUFFICIENT_BALANCE` | 400 | 用户余额不足(内部转账) | | | `TX_NOT_CREDITABLE` | 400 | 补单:交易查不到、未上链、执行失败或没有转入平台地址 | | | `RECREDIT_REJECTED` | 403 | 补单:收款地址不属于该用户 | | | `WITHDRAW_DISABLED` | 403 | 商户未开通提现 | | | `WITHDRAW_NOT_ALLOWED` | 400 | 该币种在该链上不允许提现 | | | `AMOUNT_BELOW_MIN` | 400 | 低于最小提现金额 | `minAmount` | | `DUPLICATE_REQUEST` | 409 | `biz_id` 已存在 | `withdrawId`、`status` | | `INVALID_STATE` | 409 | 当前状态不允许该操作 | | | `HOT_WALLET_INSUFFICIENT` | 409 | 平台出账钱包余额不足,请稍后重试或联系我们 | | | `SIGNER_UNAVAILABLE` | 503 | 签名服务暂不可用,可稍后重试 | | | `CHAIN_RPC_UNAVAILABLE` | 503 | 链节点暂不可用,可稍后重试 | | | `INTERNAL` | 500 | 服务器内部错误 | | ## 重试建议 * **5xx 与网络错误**:可以重试。对 `POST /v1/withdraws` 务必用**同一个 `biz_id`** 重试。 * **4xx**:修正请求后再发,原样重试不会成功。`DUPLICATE_REQUEST` 表示之前已经成功创建,按 `metadata.withdrawId` 继续即可。 * 以后可能新增 reason,未识别的请按 HTTP 状态码兜底处理。