GoldFin Open API v1
A server-to-server API for partners (distributors / integrators): gold custody accounts, XAUT physical redemption, balances, and withdrawals.
1. Introduction
The GoldFin Open API lets your platform plug into GoldFin's gold-asset layer: your users perform actions inside your product, and your system calls GoldFin via the API to hold, redeem, and settle gold.
1.1 What you can do with the API
| Capability | Description | v1 access |
|---|---|---|
| Account & permission queries | API account status, KYB status, key scopes, IP allowlist | read/write available |
| Deposits (USDT / XAUT) | Get on-chain deposit addresses, query crediting progress | query |
| Balance queries | Per-asset available / held / locked, including a "redeemable" flag | query |
| XAUT → physical gold redemption | RFQ quote → place redemption order → track settlement → pickup code / refund — the core v1 capability | full read/write |
| Sub-account → main account transfer | Explicitly consolidate XAUT from a ledger sub-account into the main account before redeeming | full read/write |
| Idle balance withdrawals | Withdraw USDT / XAUT to pre-approved whitelisted addresses (crypto only) | full read/write |
| GoldPoints GP / Referral | GP balance, GP ledger, referral summary | query |
1.2 Integration model (B2B2C)
You (the partner) register with GoldFin as a distributor account, and your end users are represented as ledger sub-accounts under your account. The API is called with your distributor identity: assets are booked in your main account and sub-accounts, and redemptions and withdrawals are initiated by your system.
Fee model: at redemption time GoldFin charges a service fee at the global rate — added on top of the redeemed amount, priced in gold weight, and deducted from your XAUT balance (e.g. at a 2% rate, redeeming 100g of physical gold deducts XAUT equivalent to 102g in total). The fee is shown transparently in the quote response (§5.4).
1.3 Prerequisites and onboarding flow
The API onboarding state machine has 4 states: not_started → sandbox_only → production_active (production is enabled directly by operations, with no pending-review intermediate state). Any enabled state can be frozen to suspended (API paused; on restore it returns to the state it was in before the freeze). See 8.1 State Machines. Key creation and management are done in the web Developer Console (/mine/api-keys).
GET /balances returns the same balance shown on the web Assets page; redemption orders placed on the web also appear in the API order list (with a source tag channel: api | web, filterable), so reconciliation can use a single data source. When the web app and the API operate on the same balance simultaneously, balance locking is first-come-first-served, and the later one receives an insufficient-balance error.2. Quickstart
# First request: GET /account (full signing example in §3.4)
curl -s "https://api-sandbox.goldfin.dev.stablehunter.ai/openapi/v1/account" \
-H "X-GF-APIKEY: gfk_sandbox_xxxxxxxx" \
-H "X-GF-TIMESTAMP: 1781136000000" \
-H "X-GF-SIGNATURE: 9f2c…hex(HMAC_SHA256)…"
3. Authentication & Signing
Every request must carry an API key and an HMAC-SHA256 signature (modeled on Binance's approach). All three headers are required:
| Header | Description |
|---|---|
X-GF-APIKEY | Public API key id (created in the Developer Console) |
X-GF-TIMESTAMP | Unix timestamp (milliseconds) at the moment the request is sent |
X-GF-SIGNATURE | hex(HMAC_SHA256(secret, signingString)) |
3.1 Signing string (signingString)
{timestamp}\n{METHOD}\n{path}\n{canonical_query}\n{sha256(body)}\n{idempotency_key_or_empty}
Idempotency-Key must be part of the signature — otherwise an attacker could swap in a fresh idempotency key within the recvWindow to replay an intercepted funds-moving request and bypass dedup. Any future security-related header must likewise be added to the signing string.Exact rules for the six segments of the signing string
| Segment | Rule |
|---|---|
timestamp | The millisecond timestamp string, identical to the X-GF-TIMESTAMP header |
METHOD | HTTP method, uppercase |
path | The request path verbatim, including the /openapi/v1 prefix, without the query, with no trailing-slash or case normalization |
canonical_query | Each key and value percent-encoded per RFC3986 (A-Za-z0-9-._~ unescaped, hex uppercase), sorted in ascending byte order by "encoded key → value", joined as k=v with &; empty string if there is no query. recvWindow is in the query and is naturally part of the signature |
sha256(body) | sha256 of the raw bytes of the request body, lowercase hex; an empty body is fixed to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
idempotency_key | The raw value of the Idempotency-Key header; empty string for routes that do not require an idempotency key |
The signature = the lowercase hex of HMAC-SHA256(secret, signingString). Header names are never part of the signing string (HTTP header names are case-insensitive).
3.2 Time window (recvWindow)
A request may carry a recvWindow parameter (milliseconds, default 5000, max 60000). The server rejects requests where abs(serverTime − timestamp) > recvWindow → 401 GF_TIMESTAMP_OUT_OF_RANGE.
Time sync: GET /time returns the server's current time (unix milliseconds, same unit as X-GF-TIMESTAMP) and can be called without a signature — sync once when integrating, then periodically calibrate your local clock drift; when you hit GF_TIMESTAMP_OUT_OF_RANGE, call it first to rule out clock drift. The timestamp is a unix timestamp and has no inherent time zone, so no time-zone conversion is needed.
recvWindow is passed as a query parameter and is therefore naturally part of the signing string's canonical_query segment — no special handling required.3.3 IP allowlist and key scopes
- Each key is bound to an IP allowlist: a source IP not on the list →
403 GF_IP_NOT_ALLOWED. Entries support a single IP or a CIDR range, up to 10 entries per key. - An empty allowlist = no source-IP restriction; but a key with the
withdrawscope must have a non-empty allowlist before it can be enabled (mirroring the exchange convention "enabling withdrawals requires an IP binding"). - Each key has scopes:
read/redeem/withdraw/withdraw_approve/pickup_code; insufficient →403 GF_SCOPE_INSUFFICIENT.withdrawinitiates withdrawals,withdraw_approveapproves large-amount reviews (these can be split across different keys to implement dual control);pickup_code(pickup authorization, bearer-grade) is independent of read and granted only to the presenting endpoint. - Keys are scoped to sandbox / production environments: a cross-environment call →
403 GF_ENVIRONMENT_MISMATCH. - The secret is shown only once at creation, so store it securely; rotation and revocation are supported (in the Developer Console).
3.4 Signing example (Python) and test vectors
import hmac, hashlib, time
def sign(secret, method, path, canonical_query, body: bytes, idem_key=""):
ts = str(int(time.time() * 1000))
body_hash = hashlib.sha256(body).hexdigest() # raw bytes, lowercase hex
signing = "\n".join([ts, method, path, canonical_query, body_hash, idem_key])
sig = hmac.new(secret.encode(), signing.encode(), hashlib.sha256).hexdigest()
return ts, sig
Official test vectors
Fixed test secret: gfsec_test_0123456789abcdef0123456789abcdef, fixed timestamp: 1750000000000. Verify your implementation against these first, then switch to a real secret — this is the first step in troubleshooting GF_SIGNATURE_INVALID.
| Vector | Input | Expected signature (hex) |
|---|---|---|
| A GET, no params | GET /openapi/v1/balances, no query, no body, no idempotency keysigningString = "1750000000000\nGET\n/openapi/v1/balances\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n" | 7633012be12091bb34c9a5b6b1e3782b210de57f8ba2a7809e44266f2dd80aeb |
| B POST, all elements | POST /openapi/v1/redemption/quotes?recvWindow=5000body = {"asset":"XAUT","amount":"100","unit":"gram","appointment_date":"2026-06-20"} (raw bytes, no whitespace)Idempotency-Key = 01JXAMPLE0000000000000000body sha256 = 33508da52c33162ab88583650ab868db1b17e60528eecb23408855d64efe4e03 | b3696c8b8da903c90e3fe9caba83e5f824f221b2d479b53be280a0a7f8c8e03f |
4. Common Conventions
4.1 Environments & Base URL
| Environment | Base URL | Notes |
|---|---|---|
| Production | https://api.goldfin.dev.stablehunter.ai/openapi/v1 | Callable with a production key once production is enabled by operations |
| Sandbox | https://api-sandbox.goldfin.dev.stablehunter.ai/openapi/v1 | Available as soon as the API is enabled |
Access entry points: the Open API is reachable only through the two official domains above — any other entry point (the main site domain, direct IP, etc.) returns 404. Sandbox and production are the same platform and the same API version, with fully isolated data: a sandbox key never sees production data, and vice versa.
Versioning: the URI carries the major version (/v1); within a version only backward-compatible incremental changes are made; breaking changes bump to /v2 and are announced in advance.
4.2 Idempotency (Idempotency-Key)
- Every POST that creates a funds-related resource must carry an
Idempotency-Keyheader (≤64 chars, client-generated, ULID/UUID recommended). - Replaying within 24 hours with the same key + the same body → returns the original first response (Stripe semantics).
- Same key + a different body →
409 GF_IDEMPOTENCY_CONFLICT. - Dedup dimension:
(api_key_id, environment, route, idempotency_key)(sandbox/production independent). - Timeout-retry guidance: on request timeout / network interruption, when you're unsure whether it succeeded, resend with the same Idempotency-Key, byte-for-byte — if it already succeeded you get the original response back, otherwise it executes normally. This is the core purpose of idempotency; funds-related operations must be implemented this way, and you must never retry with a new key (which would cause a double charge).
409 GF_IDEMPOTENCY_CONFLICT. Also note: dedup is keyed by key id, so a replay after key rotation is treated as a new request — let in-flight retries converge before rotating (see §3.3).Correct approach: serialize once, cache the raw bytes, and reuse the same bytes on retry (the signature also computes sha256(body) over these bytes, see §3.1) — do not JSON.stringify again on each retry.
// ✅ Correct: serialize the body once; bytes and Idempotency-Key stay constant across retries
const bodyBytes = Buffer.from(JSON.stringify({ quote_id: "q_123" })); // done only once
const idemKey = ulid();
const bodyHash = sha256_hex(bodyBytes); // goes into the signing string
async function send() {
return http.post(url, bodyBytes, { headers: sign(bodyBytes, idemKey, bodyHash) });
}
await retryWithBackoff(send); // every retry sends the same bodyBytes + the same idemKey
// ❌ Wrong: re-serialize on each retry — any difference in field order/whitespace/encoding → 409 GF_IDEMPOTENCY_CONFLICT
// await retryWithBackoff(() => http.post(url, JSON.stringify(payload), …));
4.3 Request tracing (X-GF-Request-Id)
Every response carries X-GF-Request-Id (a server-generated ULID), echoed in the error envelope's request_id field. Please provide this value when contacting technical support.
4.4 Pagination
List endpoints use limit (default 20, max 100) + offset (default 0), with the response envelope:
{ "items": […], "total_count": 123, "has_more": true }
Bounded small collections (deposit addresses, balances, order events) return only { "items": […] }, with no pagination fields — this is by design, not an omission.
4.45 Amounts, weight conversion, and precision
- All as strings: every amount / quantity field is a JSON string (never a number type), scientific notation is not accepted, and over-precision returns
400 GF_INVALID_REQUEST(no silent truncation). - Weight conversion constant:
1 XAUT = 1 troy ounce = 31.1034768 grams(exact value). Grams ↔ ounces are converted using this constant throughout. - Precision: gram quantities keep 3 decimal places; ounces are whole ounces; XAUT settlement keeps 8 decimal places (NUMERIC(28,8)). The service fee = redeemed amount × rate, rounded at the XAUT settlement precision using HALF-UP.
- Minimum units: gram bars min 100g, in integer multiples of 100g; ounce bars min 1oz, in whole ounces.
- No price risk: redemption is priced by gold weight (redeem 100g + 2% = XAUT equivalent to 102g) and does not move with the gold price — so there is no rounding residue arising from market movements; residue comes only from the rounding above.
4.5 Rate limits
Rate limiting is applied per api_key_id (sandbox/production counted independently). When limited → 429 + Retry-After, with X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset returned on the response.
Rate-limit tiers
| Dimension | Quota |
|---|---|
| All requests combined | 600 / minute / key |
| Funds-related creates (quote / order / transfer / withdrawal) | 60 / minute / key |
v1 does not adopt Binance-style per-route weights (to be revisited once there is real load data); quotas are adjustable on the platform side and changes are announced in advance.
Minimal integration path (required / optional)
- Required: request signing (§3), timestamp/recvWindow, an Idempotency-Key on funds-related POSTs.
- Optional: webhooks — pure polling is a supported integration mode. Recommended polling intervals: in-flight orders/withdrawals every 10 seconds, steady-state lists/balances every 60 seconds (well within the rate limit).
4.6 Error handling
All errors return a uniform envelope (a rich error, intentionally separate from the flat errors of the existing /api/*):
{
"error": {
"code": "GF_QUOTE_EXPIRED", // stable machine code; write your logic against this
"message": "Quote has expired; request a new quote.",
"request_id": "01J9X…", // same as X-GF-Request-Id
"doc_url": null,
"details": null
}
}
Error code table
| code | HTTP | Meaning |
|---|---|---|
GF_SIGNATURE_INVALID | 401 | HMAC signature verification failed |
GF_TIMESTAMP_OUT_OF_RANGE | 401 | Timestamp differs from server time by more than recvWindow |
GF_KEY_INVALID | 401 | API key does not exist / is disabled / has been revoked |
GF_IP_NOT_ALLOWED | 403 | Source IP is not on this key's allowlist |
GF_ACCOUNT_SUSPENDED | 403 | Account API is paused — only the four create endpoints (quote/order/transfer/withdrawal) return this error; queries and webhooks continue as usual |
GF_ACCOUNT_FROZEN | 403 | Account frozen by the platform (stronger than API pause) — all calls rejected, webhooks stopped |
GF_ENVIRONMENT_MISMATCH | 403 | A sandbox key calling a production endpoint, or vice versa |
GF_SCOPE_INSUFFICIENT | 403 | Key lacks the required scope (read / redeem / withdraw / withdraw_approve / pickup_code) |
GF_SUBACCOUNT_NOT_FOUND | 403 | The specified sub-account does not exist |
GF_SUBACCOUNT_FORBIDDEN | 403 | The sub-account does not belong to the caller |
GF_ADDRESS_NOT_WHITELISTED | 403 | The withdrawal address is not an approved whitelisted address |
GF_IDEMPOTENCY_CONFLICT | 409 | Same Idempotency-Key replayed but with a different body |
GF_QUOTE_EXPIRED | 409 | Quote has expired (expiry only) — just request a new quote |
GF_QUOTE_CONSUMED | 409 | Quote has already been consumed — check the order list first: the order has very likely already been created (e.g. duplicate submission); do not blindly retry placing the order |
GF_ASSET_IN_SUBACCOUNT | 422 | Main-account XAUT is insufficient but a sub-account has it → run a sub-to-main transfer first |
GF_INSUFFICIENT_SUBACCOUNT_BALANCE | 422 | Source sub-account balance is insufficient |
GF_TRANSFER_NOT_ALLOWED | 422 | Transfer rejected by policy / state |
GF_INVENTORY_UNAVAILABLE | 422 | The requested amount / appointment cannot be fulfilled (internal inventory gating; the inventory list is not exposed) |
GF_ASSET_NOT_REDEEMABLE | 422 | A non-XAUT asset entered the redemption flow |
GF_FEE_TOKEN_INVALID | 422 | Fee token expired, tampered with, or inconsistent with the request values |
GF_INVALID_REQUEST | 400 | Request parameter validation failed (malformed, illegal enum, negative amount, etc.); details[] carries per-field reasons |
GF_NOT_FOUND | 404 | Resource not found (or wrong domain/environment) — returned in the uniform envelope |
GF_INSUFFICIENT_BALANCE | 422 | Insufficient balance (neither main nor sub-account is enough); the error details carry the available balance and the requested amount |
GF_INVALID_TRANSITION | 422 | (sandbox only) The target state specified by the test helper is not a valid transition |
400 (parameter validation), 401 (auth), 403 (scope/IP/account state), 404 (resource not found), or 429 (rate limit), and these are no longer repeated in the per-endpoint docs. All errors, including 400/404/5xx, use the uniform envelope above — there are no bare-string response bodies.Which errors are retryable
- Retryable:
5xx(server failures) and429(rate limit, honorRetry-After) — retry after backoff; when retrying funds-related POSTs you must reuse the same Idempotency-Key (see §4.2), otherwise you may double-charge. - Do not retry: all other
4xx— the request itself is at fault; fix it and resend; blindly retrying a hundred times yields the same result. - Two special cases:
GF_QUOTE_EXPIRED→ request a new quote, then place the order;GF_QUOTE_CONSUMED→ check the order list first to confirm whether an order was already created, then decide the next step.
5.1 Account
Get the caller's API account profile: onboarding status, environment, KYB status, scopes, IP allowlist. Recommended as the first call after integrating, to verify connectivity and status.
Response fields
| Field | Type | Description |
|---|---|---|
account_id | string | Account ID |
owner_kind | enum | distributor | gold_exchanger |
api_status | enum | not_started / sandbox_only / production_active / suspended |
environment | enum | The current key's environment: sandbox | production |
ip_allowlist | string[] | Source IPs allowed for this key |
kyb_status | string | KYB status (8 states, reusing the existing KYB flow) |
permissions | string[] | Granted scopes |
// 200
{
"account_id": "acc_01J9X2M…",
"owner_kind": "distributor",
"api_status": "production_active",
"environment": "production",
"ip_allowlist": ["203.0.113.10"],
"kyb_status": "approved",
"permissions": ["read", "redeem", "withdraw"]
}
suspended), only the four create endpoints (quote / order / transfer / withdrawal) return 403 GF_ACCOUNT_SUSPENDED; this endpoint and all other queries remain available (the api_status field will show suspended), webhooks are delivered as usual, and in-flight orders continue to be fulfilled and remain fully trackable. Redemption orders still in the cancellable window (pending) at the moment of suspension are automatically cancelled by the system and fully refunded. Note: when an account is frozen platform-wide, all calls return 403 GF_ACCOUNT_FROZEN (webhooks also stop) — this is a separate switch from API pause.Transfer XAUT between a ledger sub-account and the main account, bidirectionally. sub_to_main: redemption requires XAUT in the main account — call this endpoint first when a quote/order returns GF_ASSET_IN_SUBACCOUNT. main_to_sub: return main-account funds to a specified sub-account — used to return a refund that landed in the main account back to the original end-user sub-account (the refund's refund.destination_sub_account_id is the suggested target). A transfer always requires you to explicitly specify the sub-account; GoldFin never selects or consolidates automatically.
Request body
| Field | Required | Description |
|---|---|---|
asset | required | Fixed XAUT |
amount | required | Decimal string |
direction | required | sub_to_main (requires source_sub_account_id) / main_to_sub (requires target_sub_account_id) |
source_sub_account_id | conditional | Required for sub_to_main — the source sub-account ID |
target_sub_account_id | conditional | Required for main_to_sub — the target sub-account ID |
target_account_scope | deprecated | Legacy from the old contract (sub_to_main only), redundant with direction — omit for new integrations |
// 201
{
"transfer_id": "trf_01J9X3…",
"asset": "XAUT", "amount": "2.5",
"direction": "sub_to_main",
"source_sub_account_id": "sub_8842",
"status": "completed", // a ledger-level operation, usually completes synchronously
"created_at": "2026-06-11T08:00:00Z",
"completed_at": "2026-06-11T08:00:00Z"
}
Errors
| code | Trigger |
|---|---|
403 GF_SUBACCOUNT_NOT_FOUND / _FORBIDDEN | Sub-account does not exist / does not belong to you (no transfer record is produced) |
409 GF_IDEMPOTENCY_CONFLICT | Idempotency-key conflict |
422 GF_INSUFFICIENT_SUBACCOUNT_BALANCE | Sub-account balance insufficient |
422 GF_TRANSFER_NOT_ALLOWED | Policy / state does not allow the transfer |
failed is a terminal state: to retry, initiate a new transfer with a new Idempotency-Key; replaying the old key just returns the original failed response.Query transfer status: pending → processing → completed | failed. On failure it returns failure_reason + error_code.
GET /account/transfers) and a main-account asset/fee ledger query (GET /account/ledger, with each entry typed: redemption debit / service fee / refund / transfer / deposit / withdrawal). Each ledger entry carries a ledger_id, a monotonically increasing seq, and a balance_after — reconcile/compensate by seq, so month-end reconciliation needs no self-built shadow ledger.5.2 Funding (on-chain USDT / XAUT)
Get the account's on-chain deposit addresses (by asset / network). v1 deposits support on-chain USDT and direct XAUT deposits; fiat deposits are out of scope for v1. Deposit addresses belong to the account layer; once credited, assets can be partitioned to sub-accounts via the ledger (a bookkeeping-layer operation).
// 200
{ "items": [
{ "asset": "USDT", "network": "TRON", "address": "TX8c…", "memo": null },
{ "asset": "XAUT", "network": "ETH", "address": "0x9a…", "memo": null }
]}
List deposit records and crediting status (on-chain settlement status). Fields: deposit_id / asset / amount / source(=onchain) / status / tx_hash / created_at. Pair with the deposit.credited webhook to avoid polling.
5.3 Balances
Per-asset balances. Asset-center view: custody location is GoldFin's internal matter and is never exposed.
| Field | Type | Description |
|---|---|---|
asset | enum | XAUT / USDT / GP / XAUE |
available / held / locked | string | available = held − locked − compliance hold − in-flight; locked = the amount frozen by active redemption orders (a business freeze) |
compliance_hold | string | Compliance / risk-control freeze (KYT / screening / Travel Rule), distinct from the business locked — not usable and already deducted from available. v1 provides only this bucket; the full compliance state machine (risk_level / screening / Travel Rule fields) is left to v2. |
query_only | bool | true for USDT / GP / XAUE in v1 |
redemption_eligible | bool | true only when the asset is XAUT in the main account; sub-account XAUT must be transferred first |
// 200
{ "items": [
{ "asset": "XAUT", "available": "12.5", "held": "15.0", "locked": "2.5", "compliance_hold": "0",
"query_only": false, "redemption_eligible": true },
{ "asset": "GP", "available": "150000", "held": "150000", "locked": "0",
"query_only": true, "redemption_eligible": false }
]}
GET /sub-accounts (list + per-asset balances per account, paginated) and GET /sub-accounts/{sub_account_id}/ledger (a single sub-account's ledger, paginated). The id is the same one used in a transfer's source_sub_account_id: review the books here first, then decide which sub-account to transfer from. Sub-account creation and management are done on the web; the API provides no write operations. A sub-account is a platform bookkeeping unit, holds no on-chain address, and does not expose custody location.held = total held, locked = the business freeze from active redemption orders, compliance_hold = compliance/risk-control freeze (distinct from locked), available = held − locked − compliance_hold; the lock/actual-deduction timing of XAUT across order states is in the funds-semantics table in §8.1.5.4 RFQ / Quotes
Request an XAUT → physical redemption quote. Returns a quote_id + validity period; the price and service fee are snapshot-locked at quote time.
Request body
| Field | Required | Description |
|---|---|---|
asset | required | Fixed XAUT (the only redeemable asset in v1) |
amount | required | Decimal string |
unit | required | gram (gold bar min 100g) | ounce (min 1oz) |
appointment_date | required | Appointed pickup date: earliest T+3 (counted in local business days at the pickup point), latest +30 days. Required — this date drives inventory lookup and locking, consistent with the current online logic and evolving with it |
dry_run | optional | Pre-check mode: when true, only validates inventory/fulfillability and returns the same fee fields, but does not issue a quote_id (status=dry_run), locks no resources, and does not count against the funds-related 60/minute quota — used to try out dates/specs/quantities without consuming your order budget or leaving residual quotes behind. Unfulfillable still returns 422 GF_INVENTORY_UNAVAILABLE. |
// 201 (includes "the four numbers")
{
"quote_id": "qt_01J9X4…",
"asset": "XAUT",
"amount": "100", "unit": "gram", // ① redeemed amount
"fee_rate_pct": "2.0", // ② fee rate snapshot (global rate; API orders apply no GP offset)
"service_fee_xaut": "0.0643", // ③ service fee (= XAUT equivalent to 2g)
"total_payable_xaut": "3.2794", // ④ total payable (= XAUT equivalent to 102g, actually deducted at order time)
"reference_price": "3342.10", // reference price (XAUT/USD), display only
"appointment_date": "2026-06-20",
"expires_at": "2026-06-12T08:05:00Z", // 5-minute validity
"status": "open" // open | consumed | expired
}
GF_INVENTORY_UNAVAILABLE.Errors
| code | Trigger |
|---|---|
422 GF_INVENTORY_UNAVAILABLE | The requested amount / appointment date cannot be fulfilled (internal inventory gating) |
422 GF_ASSET_IN_SUBACCOUNT | Main-account XAUT insufficient, sub-account has it → call POST /account/transfers first |
422 GF_ASSET_NOT_REDEEMABLE | Non-XAUT asset |
Query quote status (open / consumed / expired).
5.5 Redemption Orders
Create a redemption order, consuming a valid quote_id (binding the locked price). v1 applies no GP offset; the service fee is charged in full per the quote snapshot.
Request body
| Field | Required | Description |
|---|---|---|
quote_id | required | A valid, unconsumed quote |
pickup_ref | optional | Your pickup reference number (echoed back verbatim) |
// 201
{
"order_id": "ord_01J9X5…",
"status": "pending",
"asset": "XAUT", "amount": "100", "unit": "gram",
"pickup_code": null, // always null — the pickup code is only issued by POST .../pickup-code (requires the pickup_code scope), see the order-detail section
"pickup_ref": "wsb-20260611-001", // the reference number you passed in, echoed back
"appointment_date": "2026-06-16",
"refund": null,
"created_at": "2026-06-11T08:01:30Z"
}
Errors
| code | Trigger |
|---|---|
409 GF_QUOTE_EXPIRED | Quote has expired (expiry only) — request a new quote, then place the order |
409 GF_QUOTE_CONSUMED | Quote already consumed — check the order list first (an order has very likely been created); do not blindly retry |
409 GF_IDEMPOTENCY_CONFLICT | Idempotency-key conflict |
422 GF_ASSET_IN_SUBACCOUNT | Main-account XAUT insufficient (response includes action=transfer_to_main guidance) |
Paginated list of redemption orders — including orders placed on the web (the API is the complete view of the account's orders). Orders carry a source tag channel: api | web that you can filter on.
Order detail: status, pickup code, pickup location and time, completion and refund information. The 13 states are in 8.1 State Machines.
source_sub_account_id was provided at order time, the refund's refund.destination = sub_account and refund.destination_sub_account_id gives the original sub-account — you can then initiate a main_to_sub transfer to return the funds to the original end-user sub-account, closing the B2B2C attribution chain (no self-built shadow ledger needed).Pickup: the pickup code is a 9-digit dynamic numeric code (you render it as a QR code for the user to present, and the gold service provider scans it to redeem) — short-lived, around 30 seconds, rotated on each issuance with the old code immediately invalidated (forwarded screenshots are useless). The pickup code can only be obtained via the dedicated endpoint
POST /redemption/orders/{id}/pickup-code, and requires the separate pickup_code scope; GET /redemption/orders/{id} no longer returns or refreshes the pickup code (its pickup_code is always null). Why: under the old design each GET would rotate and invalidate the previous code, so concurrent GETs from multiple systems (user app / support / monitoring / reconciliation) would "kick each other's codes" and cause counter scans to fail — now only the presenting endpoint with the pickup_code scope can issue, while read-only keys for reconciliation/reporting/support cannot obtain it. While the user is presenting, the presenting endpoint calls POST on demand to fetch the latest code (the response includes expires_at); never cache it anywhere. Visibility window: confirmed / payment_pending / payment_sent; it is permanently hidden after redemption. The order response returns the pickup location and time window (pickup_location.address / time_window); v1 pickup point is Hong Kong only.Rescheduling: v1 does not support rescheduling (consistent with the web app, and there is no manual reschedule channel) — to reschedule = cancel, then re-quote and re-order. Price risk: re-ordering necessarily means a new quote. Although the XAUT payable at order time is computed by gold weight (independent of the spot gold price), actual settlement with the gold provider uses USDT — once the appointment succeeds (confirmed), the redeemed XAUT has already been converted to USDT; therefore changing/re-ordering after the appointment succeeds incurs an XAUT↔USDT round trip, so there is gold-price (XAUT/USDT) exposure, borne by the party placing the order. (Cancelling during the
pending stage involves only frozen funds with no conversion yet, so the full refund carries no such exposure.) We recommend stating clearly in your end-user agreement/front-end copy: rescheduling = cancel the old order + re-order at a new quote, with price fluctuation borne by the user. Handling of overdue uncollected orders (late penalty + auto-cancel) is a future feature.Order status-transition history (audit / reconciliation). Each event: from_status / to_status / at / actor. actor ∈ system | ops | partner — manual fallback operations (manual ops) also land in the event stream, ensuring status is queryable and reconcilable without relying on verbal sync.
Cancel a redemption order — cancellable only in the pending state (per the state machine). A non-cancellable state → 409.
5.6 Withdrawals (crypto only)
Withdraw idle balance (USDT / XAUT) to a pre-approved whitelisted address. Adding and reviewing whitelisted addresses is done on the web; v1 does not support fiat withdrawals. Flow: ① get a fee snapshot → ② place a withdrawal with the fee_token → ③ query status / receive a webhook.
// 200
{
"asset": "USDT", "network": "TRON",
"network_fee": "1.2", // real-time network fee
"platform_fee": "0.8", // platform markup
"total_fee": "2.0",
"fee_token": "eyJ…sig", // HMAC-signed, 120s TTL, sent back on POST /withdrawals
"expires_at": "2026-06-11T08:03:00Z"
}
Request body
| Field | Required | Description |
|---|---|---|
asset | required | USDT | XAUT |
network | required | Chain network |
amount | required | Decimal string |
address_id | required | Must point to an approved whitelisted address |
fee_token | required | From GET /withdrawal/fee; expired or inconsistent values → 422 |
Errors
| code | Trigger |
|---|---|
403 GF_ADDRESS_NOT_WHITELISTED | Address is not on the approved whitelist (no withdrawal record is produced) |
422 GF_FEE_TOKEN_INVALID | fee_token expired / tampered with / inconsistent with the request values |
Withdrawal state machine: (pending_review →) approved → submitted → broadcasted → completed | rejected | failed, with tx_hash after completion. Small amounts (below the manual-review threshold) start directly from approved (the whitelisted address was pre-reviewed, so no per-withdrawal in-flight approval is needed); large amounts (≥ threshold, configurable per asset/network via system_config) first enter pending_review and require a key holding withdraw_approve or operations to approve via POST /withdrawals/{id}/approve (rejection → rejected).
withdraw scope must be enabled by operations ② the key must be bound to a non-empty IP allowlist ③ withdrawals can only go to approved whitelisted addresses — and adding an address is done on the web and protected by TOTP, so the second factor is moved forward to address management.Amount details:
amount is the gross amount — what is deducted from the balance is the amount, and the on-chain credited amount = amount − total fee (network fee + platform markup, both snapshotted in the fee_token); amounts allow at most 8 decimal places, over-precision returns 400 GF_INVALID_REQUEST (no silent truncation); full network set: USDT → TRON / ETH / ARBITRUM / SOL / POL, XAUT → ETH only; the API channel applies no GP offset.Amount risk control: the whitelist only controls the destination, not the amount. A single-transaction cap + a rolling 24h cumulative cap apply (per asset/network, configurable via system_config); exceeding the limit returns
422 GF_WITHDRAWAL_LIMIT_EXCEEDED (details carry limit/window/remaining). Large-amount review: withdrawals ≥ the review threshold first enter pending_review and are approved by a key holding withdraw_approve or by operations via POST /withdrawals/{id}/approve — withdraw (initiate) and withdraw_approve (approve) can be split across different keys to implement dual control. Behavioral risk control (new IP / off-hours / frequency) is left to v2.5.7 GoldPoints GP (read-only)
GP (GoldPoint): 1 GP = 1mg of gold. In v1 the API only provides queries; earning and spending GP (converting to XAUT / cashing out) is done on the web.
{ "balance_gp": 150000, "query_only": true } // 150000 GP = 150g of gold; this endpoint returns an integer, while GP in /balances is returned as a string (consistent with the other asset balance fields)
GP ledger: kind ∈ gp_earned | gp_redeemed, amount, at.
5.8 Referral (read-only)
{ "referred_count": 12, "query_only": true }
6. Webhooks
Key asynchronous events are pushed proactively, avoiding polling. All events use a uniform envelope:
{
"event_id": "evt_01J9X6…", // dedup key
"event_sequence": 10342, // global monotonic sequence; missed deliveries are replayed via GET /events?since_sequence
"type": "redemption.order.status_changed",
"created_at": "2026-06-11T08:05:00Z",
"data": { /* resource snapshot, including the object version */ }
}
Event types (v1)
| type | Trigger |
|---|---|
redemption.order.status_changed | Redemption order status transition |
deposit.credited | Deposit credited |
withdrawal.status_changed | Withdrawal status transition |
Event content data
{
"data": {
"object": { /* full resource snapshot — structurally identical to the corresponding GET endpoint's response */ },
"previous_status": "payment_sent" // carried only by *.status_changed events
}
}
data.objectshares the same structure as the query endpoints: an order event carries the entire RedemptionOrder, a deposit event a Deposit, a withdrawal event a Withdrawal — one set of deserialization code serves both.pickup_codenever appears in notifications (always null): the pickup code rotates roughly every 30 seconds and expires on delivery, and notification bodies end up in your server logs — the pickup code can only be obtained via the dedicated endpointPOST /redemption/orders/{id}/pickup-code(requires thepickup_codescope).- Why there is no transfer event: transfers complete synchronously — the response of
POST /account/transfersreturns a terminal state on the spot (completed / failed); there is no "result arrives later" wait, so no notification is needed.
Delivery headers
| Header | Description |
|---|---|
X-GF-Webhook-Timestamp | Send time in unix seconds (replay window ±300s) |
X-GF-Webhook-Signature | v1=hex(HMAC_SHA256(webhook_secret, "{timestamp}.{raw_body}")); during the secret-rotation grace period both the new and old signatures are carried (v1=xxx,v1=yyy), and verifying either one suffices |
X-GF-Event-Id / X-GF-Event-Type | Dedup key / event type (redundant with the body, convenient for routing before parsing; the body covered by the signature is authoritative) |
X-GF-Delivery-Id | Unique per delivery (changes on retry, while event_id stays the same); report it to support when troubleshooting |
Signature verification
- Compute HMAC-SHA256 over the raw bytes of
"{timestamp}.{raw_body}"and compare it in constant time against the hex afterv1=in the signature header (thev1=prefix is the signature-scheme version; future upgrades will addv2=, backward compatibly). - Replay window 300 seconds: anything older is rejected.
- Dedup by
event_id: delivery semantics are at-least-once. - Determine state by
version, not by arrival order: delivery is not guaranteed to be ordered. Each resource snapshot (order / withdrawal / transfer / deposit) carries a monotonically increasingversion— apply an event only when itsobject.versionis higher than the version you have stored, otherwise discard it. Never let webhook arrival order decide the final state, or out-of-order/re-deliveries will roll the state back to an old value. - Missed deliveries are backstopped by event replay: each event carries a global monotonic
event_sequence; when you miss a webhook, callGET /events?since_sequence={the highest sequence you've processed}to replay all missed events in one shot (the authoritative backstop, no per-resource polling needed). The ledger additionally carries aseqfor line-by-line checking — under concurrent writes, an offset+limit list alone is not a reliable gap-detection method, so use the sequence. - Verify the signature first, return 2xx quickly, and handle business logic asynchronously.
- The callback URL must be https (validated when configuring it in the console).
Retry schedule
After a delivery failure, retries occur at roughly 1 min / 5 min / 30 min / 2 hr / 6 hr / 24 hr / 24 hr, for 8 attempts total over about 3 days; after that it is marked failed, viewable in the Developer Console under "Recent deliveries". v1 does not provide manual re-send — when you do not receive an event, use the GET /events replay below as a backstop.
Event replay GET /events
Replay this account's event stream in ascending global event_sequence order — the same event objects pushed by webhooks — the authoritative backstop after a missed or out-of-order delivery. Cursor style: pass since_sequence = the highest sequence you've processed, and it returns the events after it + has_more; idempotent and repeatably readable.
// GET /events?since_sequence=10341&limit=100 → 200
{ "items": [
{ "event_id": "evt_…", "event_sequence": 10342, "type": "withdrawal.status_changed", "created_at": "…", "data": { /* same snapshot as the GET response */ } }
],
"total_count": 1, "has_more": false
}
- While the account is
suspended(API paused), delivery continues as usual (API pause only blocks new business creation, see §8.2 FAQ); delivery stops only on an account-level freeze.
How to configure
- Webhooks are configured in the Developer Console web app; v1 does not provide a webhook management API.
- Each environment (sandbox / production) has at most one callback URL, each with its own signing secret; the secret is shown only once at creation and can be rotated.
- The console lets you select which event types to subscribe to, provides a "Send test event" button, and lets you view recent deliveries (succeeded / failed / retrying).
- Advancing order status in the sandbox (see §7 test helpers) triggers webhooks as usual — signature verification and callback handling can be fully exercised in the sandbox.
7. Sandbox Guide
- Sandbox-first: enabling the API grants sandbox access, so you can create a sandbox key and start integrating immediately.
- Production keys can be created once production is enabled by operations (
production_active). - Sandbox and production use different keys and different Base URLs; a cross-environment call →
403 GF_ENVIRONMENT_MISMATCH. - Production is enabled directly by operations (KYB must already be approved) →
production_active; there is no pending-review intermediate state.
7.0 Sandbox shape and behavior
Sandbox and production are the same platform and the same API version (a test-mode shape, not a standalone environment) — the contract you validate in the sandbox is the production contract, with no version drift. There are only two differences: data is fully isolated, and all fund movements are simulated.
| Behavior | Sandbox | Production |
|---|---|---|
| Data visibility | Fully isolated — a sandbox key never sees production data (including real web data), and vice versa; cross-environment resource lookups always return 404 | |
| Funds / fulfillment | Everything simulated: no on-chain activity, no real inventory movement, no physical delivery | Real funds and fulfillment |
| Balance source | POST /sandbox/deposits simulates a deposit (i.e. the test-funds faucet) | Real on-chain deposits |
| Order status advancement | Explicitly advanced by calling the test helper (see 7.0.1); no automatic placement/waiting | Real business transitions |
| webhook | Isomorphic delivery — each environment uses its own callback URL and secret, with identical event envelopes and signature verification | |
| Idempotency / rate limit | Counted independently per environment, with no cross-effect | |
7.0.1 Test helper endpoints (sandbox-only; 404 under the production domain)
| Endpoint | Purpose |
|---|---|
POST /sandbox/deposits | Simulate a deposit credit (asset + amount), triggering deposit.credited |
POST /sandbox/redemption/orders/{id}/advance | Advance a redemption order to the next state, or use target_status to specify a target state (including failure branches), triggering redemption.order.status_changed; an illegal transition → 422 GF_INVALID_TRANSITION |
POST /sandbox/withdrawals/{id}/advance | Advance a withdrawal to a specified terminal state (completed / failed), triggering withdrawal.status_changed |
All three endpoints require an Idempotency-Key (same semantics as the production endpoints). Advancement is explicitly triggered rather than time-simulated — you can write the full loop (deposit → quote → order → advance to completed → receive webhook → reconcile) as an automated test case in your own CI, and failure branches (insufficient inventory, withdrawal failure, etc.) can be reproduced deterministically too.
7.1 Support & Appeals
- Technical support: email channel (address to be announced); please include
X-GF-Request-Idand the timestamp in your message. - Maintenance & availability: v1 does not yet provide maintenance-window announcements / a status page / SLA commitments.
- Account suspended: appeal via the support email, or contact your sales representative. After API access is restored, re-reconcile through the query endpoints.
8.1 State Machines
Redemption order (RedemptionStatus, 13 states, reusing the existing FSM)
refund field)
- Only
pendingcan be actively cancelled by the partner. pickup_codeobtainable window: the three statesconfirmed / payment_pending / payment_sent, issued viaPOST .../pickup-code(requires thepickup_codescope) (a dynamic rotating code, ~30s lifetime, rotated on each issuance); after the gold service provider scans to redeem it is permanently hidden (latched, so it never reappears even if the state abnormally rolls back).- Wire format is snake_case, exactly matching the existing system enums.
Funds-semantics table
Balance basis: held = total held, locked = amount frozen by active orders, available = held − locked. The fund action, what you should do, and what to show the end customer at each order state:
| State | Fund action | Partner should | Show to end customer |
|---|---|---|---|
pending | Freeze the total payable XAUT (held unchanged, locked +, available −) | Wait; cancellable in this window (full unfreeze) | "Order submitted" |
submitting | Freeze maintained | Wait (no longer cancellable) | "Appointment processing" |
confirmed | Freeze maintained | Pickup code becomes obtainable | "Appointment confirmed, pickup available on {date}" |
payment_pending → payment_sent | Actual deduction (locked −, held −; the freeze is settled) | No action; the pickup code remains available | same as above |
verified | — (funds already deducted) | Pickup code redeemed and hidden; the user has arrived at the store | "Redeemed / delivering" |
delivery_requested → awaiting_user_receipt | — | Wait for receipt confirmation (auto-completes after 24h) | "Please confirm receipt" |
completed ✓ | — (terminal state) | Reconcile and archive | "Redemption complete" |
cancelled / booking_failed | Full unfreeze and refund (including any deducted service fee, returned to available) | Check the refund field; can re-quote and re-order | "Order cancelled/failed, funds refunded" |
payment_failed / delivery_failed | Full refund per the refund rules | Contact support (manual fallback channel) | "Processing error, funds refunded" |
Push-timing tip: when you receive a status_changed to confirmed, you can notify the customer "you can go pick it up now" (with the appointment date and pickup location).
Transfer (AccountTransfer, 4 states)
Withdrawal (Withdrawal, 5 states)
API onboarding status (api_status, 4 states)
8.2 FAQ
| Question | Answer |
|---|---|
What is the fee in the quote? Is the rate the same for everyone? | The physical-redemption service fee, snapshotted at quote time at a single global rate (no per-partner differentiated rates). It is added on top of the redeemed amount, priced by gold weight, and deducted from XAUT: redeem 100g at a 2% rate → XAUT equivalent to 102g is deducted in total. |
| Can I use GP to offset the service fee when placing a redemption order? | Not in v1. GP offset goes through the web flow; or first convert GP to XAUT on the web. |
| How are funds refunded when an order is cancelled / cannot be fulfilled? | The frozen funds are fully refunded to the main-account XAUT balance, and any deducted service fee is refunded along with it; all abnormal terminal states follow the same rule. |
| How does the user pick up? | By presenting the order's pickup QR code at the appointed pickup point (v1 Hong Kong only), where the gold service provider scans it to redeem; the order info includes the pickup location and time. |
Why does a quote / order return GF_ASSET_IN_SUBACCOUNT? | Redemption requires XAUT in the main account. First call POST /account/transfers to explicitly move the sub-account XAUT to the main account, then re-quote. |
| Which custodian holds my XAUT? | The custody location is GoldFin's internal matter, neither exposed nor something you need to care about — balance and redeemability are determined by /balances. |
| What happens when the account is suspended? | Only the four create endpoints (quote / order / transfer / withdrawal) return 403 GF_ACCOUNT_SUSPENDED; queries continue as usual (GET /account shows the status), webhooks continue, and in-flight orders keep being fulfilled and remain fully trackable. Redemption orders still in the cancellable window (pending) at the moment of suspension are automatically cancelled by the system and fully refunded (including fees), and a redemption.order.status_changed is pushed. For appeals, see the Developer Console web app. |
| Are "API pause" and an account freeze the same thing? | No. API pause only blocks new Open API business; an account-level freeze is a stronger switch — all API calls return 403 GF_ACCOUNT_FROZEN and webhooks also stop. |
| How do I add a withdrawal address? | v1 only supports adding + reviewing whitelisted addresses on the web; API withdrawals must reference an approved address_id. |
GoldFin Open API v1 developer documentation