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

CapabilityDescriptionv1 access
Account & permission queriesAPI account status, KYB status, key scopes, IP allowlistread/write available
Deposits (USDT / XAUT)Get on-chain deposit addresses, query crediting progressquery
Balance queriesPer-asset available / held / locked, including a "redeemable" flagquery
XAUT → physical gold redemptionRFQ quote → place redemption order → track settlement → pickup code / refund — the core v1 capabilityfull read/write
Sub-account → main account transferExplicitly consolidate XAUT from a ledger sub-account into the main account before redeemingfull read/write
Idle balance withdrawalsWithdraw USDT / XAUT to pre-approved whitelisted addresses (crypto only)full read/write
GoldPoints GP / ReferralGP balance, GP ledger, referral summaryquery
v1 boundaries (locked): the redemption main path only allows XAUT → physical; USDT / GP / XAUE are read-only; API redemption does not apply GP offset (GP offset goes through the web app); fiat deposits / withdrawals are out of scope for v1; the inventory list is not exposed; custody venues are not exposed.

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.

Two layers, don't conflate them: ① on-chain deposit addresses belong to your account layer (see §5.2); ② ledger sub-accounts are bookkeeping units inside your account (creating one moves no assets and holds no independent on-chain address), used to partition assets by your end user. In v1, sub-accounts cannot directly initiate physical redemption — XAUT in a sub-account must first be transferred to the main account (§5.1 transfer-to-main).

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

Register distributor account Complete KYB Enable API (Sandbox granted automatically) Production enabled by operations

The API onboarding state machine has 4 states: not_startedsandbox_onlyproduction_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).

Already a GoldFin distributor? The API account is your existing web distributor account — same account, same assets. Just enable the API in the Developer Console; no re-registration needed. The API exposes a subset of your account's capabilities: 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

5 steps to your first Sandbox redemption
1Create an API keyLog in → Developer Console → create a Sandbox key (the secret is shown only once)
2Sign your first requestUsing key + secret, sign per §3 and call GET /account to verify connectivity
3Check balanceGET /balances to confirm the main account has redeemable XAUT (the sandbox pre-funds test balances)
4Request a quotePOST /redemption/quotes to get a quote_id (with a price and service-fee snapshot)
5Place a redemption orderPOST /redemption/orders to consume the quote_id, then poll the order status or receive a webhook
# First request: GET /account (full signing example in §3.4)
curl -s "https://api-sandbox.staging.goldfin.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:

HeaderDescription
X-GF-APIKEYPublic API key id (created in the Developer Console)
X-GF-TIMESTAMPUnix timestamp (milliseconds) at the moment the request is sent
X-GF-SIGNATUREhex(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}
Security note: the 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

SegmentRule
timestampThe millisecond timestamp string, identical to the X-GF-TIMESTAMP header
METHODHTTP method, uppercase
pathThe request path verbatim, including the /openapi/v1 prefix, without the query, with no trailing-slash or case normalization
canonical_queryEach 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_keyThe 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) > recvWindow401 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

Key rotation policy: rotation issues a new key id + secret; the old key enters a 24-hour grace period (during which its scopes are unchanged and it still verifies normally) and expires automatically when the period ends; the console can revoke the old key early at any time. Note: idempotency dedup is keyed by key id — replaying an old request with the new key after rotation is treated as a new request, so let in-flight retries converge before rotating (see §4.2). Webhook secret rotation works the same way (dual signatures during the grace period, see §6).

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.

VectorInputExpected signature (hex)
A
GET, no params
GET /openapi/v1/balances, no query, no body, no idempotency key
signingString = "1750000000000\nGET\n/openapi/v1/balances\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n"
7633012be12091bb34c9a5b6b1e3782b210de57f8ba2a7809e44266f2dd80aeb
B
POST, all elements
POST /openapi/v1/redemption/quotes?recvWindow=5000
body = {"asset":"XAUT","amount":"100","unit":"gram","appointment_date":"2026-06-20"} (raw bytes, no whitespace)
Idempotency-Key = 01JXAMPLE0000000000000000
body sha256 = 33508da52c33162ab88583650ab868db1b17e60528eecb23408855d64efe4e03
b3696c8b8da903c90e3fe9caba83e5f824f221b2d479b53be280a0a7f8c8e03f

4. Common Conventions

4.1 Environments & Base URL

EnvironmentBase URLNotes
Productionhttps://api.staging.goldfin.ai/openapi/v1Callable with a production key once production is enabled by operations
Sandboxhttps://api-sandbox.staging.goldfin.ai/openapi/v1Available 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)

"Same body" definition = byte-for-byte identical (sha256 comparison over the raw request bytes): when retrying, reuse the exact bytes serialized the first time — do not re-serialize. A different JSON field order counts as a different body → 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.

All v1 list endpoints uniformly use offset+limit; cursor pagination will be evaluated in v2 once ledger data volume actually grows.

4.45 Amounts, weight conversion, and precision

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

DimensionQuota
All requests combined600 / 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)

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

codeHTTPMeaning
GF_SIGNATURE_INVALID401HMAC signature verification failed
GF_TIMESTAMP_OUT_OF_RANGE401Timestamp differs from server time by more than recvWindow
GF_KEY_INVALID401API key does not exist / is disabled / has been revoked
GF_IP_NOT_ALLOWED403Source IP is not on this key's allowlist
GF_ACCOUNT_SUSPENDED403Account API is paused — only the four create endpoints (quote/order/transfer/withdrawal) return this error; queries and webhooks continue as usual
GF_ACCOUNT_FROZEN403Account frozen by the platform (stronger than API pause) — all calls rejected, webhooks stopped
GF_ENVIRONMENT_MISMATCH403A sandbox key calling a production endpoint, or vice versa
GF_SCOPE_INSUFFICIENT403Key lacks the required scope (read / redeem / withdraw / withdraw_approve / pickup_code)
GF_SUBACCOUNT_NOT_FOUND403The specified sub-account does not exist
GF_SUBACCOUNT_FORBIDDEN403The sub-account does not belong to the caller
GF_ADDRESS_NOT_WHITELISTED403The withdrawal address is not an approved whitelisted address
GF_IDEMPOTENCY_CONFLICT409Same Idempotency-Key replayed but with a different body
GF_QUOTE_EXPIRED409Quote has expired (expiry only) — just request a new quote
GF_QUOTE_CONSUMED409Quote 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_SUBACCOUNT422Main-account XAUT is insufficient but a sub-account has it → run a sub-to-main transfer first
GF_INSUFFICIENT_SUBACCOUNT_BALANCE422Source sub-account balance is insufficient
GF_TRANSFER_NOT_ALLOWED422Transfer rejected by policy / state
GF_INVENTORY_UNAVAILABLE422The requested amount / appointment cannot be fulfilled (internal inventory gating; the inventory list is not exposed)
GF_ASSET_NOT_REDEEMABLE422A non-XAUT asset entered the redemption flow
GF_FEE_TOKEN_INVALID422Fee token expired, tampered with, or inconsistent with the request values
GF_INVALID_REQUEST400Request parameter validation failed (malformed, illegal enum, negative amount, etc.); details[] carries per-field reasons
GF_NOT_FOUND404Resource not found (or wrong domain/environment) — returned in the uniform envelope
GF_INSUFFICIENT_BALANCE422Insufficient balance (neither main nor sub-account is enough); the error details carry the available balance and the requested amount
GF_INVALID_TRANSITION422(sandbox only) The target state specified by the test helper is not a valid transition
Every endpoint may return 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

5.1 Account

GET/account
scope readidempotency naturally idempotent

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

FieldTypeDescription
account_idstringAccount ID
owner_kindenumdistributor | gold_exchanger
api_statusenumnot_started / sandbox_only / production_active / suspended
environmentenumThe current key's environment: sandbox | production
ip_allowliststring[]Source IPs allowed for this key
kyb_statusstringKYB status (8 states, reusing the existing KYB flow)
permissionsstring[]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"]
}
API-pause semantics: when an account is suspended (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.
POST/account/transfers
scope redeemidempotency Idempotency-Key required

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

FieldRequiredDescription
assetrequiredFixed XAUT
amountrequiredDecimal string
directionrequiredsub_to_main (requires source_sub_account_id) / main_to_sub (requires target_sub_account_id)
source_sub_account_idconditionalRequired for sub_to_main — the source sub-account ID
target_sub_account_idconditionalRequired for main_to_sub — the target sub-account ID
target_account_scopedeprecatedLegacy 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

codeTrigger
403 GF_SUBACCOUNT_NOT_FOUND / _FORBIDDENSub-account does not exist / does not belong to you (no transfer record is produced)
409 GF_IDEMPOTENCY_CONFLICTIdempotency-key conflict
422 GF_INSUFFICIENT_SUBACCOUNT_BALANCESub-account balance insufficient
422 GF_TRANSFER_NOT_ALLOWEDPolicy / 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.
GET/account/transfers/{transfer_id}
scope read

Query transfer status: pending → processing → completed | failed. On failure it returns failure_reason + error_code.

Reconciliation capability: transfer records and fee ledgers (including service fees, withdrawal fees, and other charge details) are a platform-level display capability — the web app and the API share the same source. Provided: a transfer list endpoint (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/funding/deposit-addresses
scope readparams asset (optional: 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 }
]}
GET/funding/deposits · GET/funding/deposits/{deposit_id}
scope readpagination limit / offset

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

GET/balances
scope read

Per-asset balances. Asset-center view: custody location is GoldFin's internal matter and is never exposed.

FieldTypeDescription
assetenumXAUT / USDT / GP / XAUE
available / held / lockedstringavailable = held − locked − compliance hold − in-flight; locked = the amount frozen by active redemption orders (a business freeze)
compliance_holdstringCompliance / 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_onlybooltrue for USDT / GP / XAUE in v1
redemption_eligiblebooltrue 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 }
]}
Sub-account queries: v1 provides read-only sub-account capabilities — 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.
Balance semantics: 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

POST/redemption/quotes
scope redeemidempotency Idempotency-Key required

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

FieldRequiredDescription
assetrequiredFixed XAUT (the only redeemable asset in v1)
amountrequiredDecimal string
unitrequiredgram (gold bar min 100g) | ounce (min 1oz)
appointment_daterequiredAppointed 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_runoptionalPre-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
}
Fee formula: the service fee is added on top of the redeemed amount, priced by gold weight, and deducted from your XAUT balance — redeem 100g at a 2% rate → XAUT equivalent to 102g is deducted in total (100g principal + 2g service fee). The total payable is converted by gold weight and does not move with the gold price — which is also why the quote validity can be relaxed to 5 minutes: re-quoting after expiry is only to re-validate inventory and appointment availability; there is no price risk. Appointment basis: T+3 is counted in local business days at the pickup point (v1 pickup point is Hong Kong only). Denominations: both gram and ounce specs exist and are derived dynamically from inventory data — there is no static denomination list; the quote/order validates against inventory, and unfulfillable returns GF_INVENTORY_UNAVAILABLE.
Quote validity = 5 minutes (adjustable by the platform, with advance notice of changes).

Errors

codeTrigger
422 GF_INVENTORY_UNAVAILABLEThe requested amount / appointment date cannot be fulfilled (internal inventory gating)
422 GF_ASSET_IN_SUBACCOUNTMain-account XAUT insufficient, sub-account has it → call POST /account/transfers first
422 GF_ASSET_NOT_REDEEMABLENon-XAUT asset
GET/redemption/quotes/{quote_id}
scope read

Query quote status (open / consumed / expired).

5.5 Redemption Orders

POST/redemption/orders
scope redeemidempotency Idempotency-Key required

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

FieldRequiredDescription
quote_idrequiredA valid, unconsumed quote
pickup_refoptionalYour 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

codeTrigger
409 GF_QUOTE_EXPIREDQuote has expired (expiry only) — request a new quote, then place the order
409 GF_QUOTE_CONSUMEDQuote already consumed — check the order list first (an order has very likely been created); do not blindly retry
409 GF_IDEMPOTENCY_CONFLICTIdempotency-key conflict
422 GF_ASSET_IN_SUBACCOUNTMain-account XAUT insufficient (response includes action=transfer_to_main guidance)
GET/redemption/orders
scope readpagination limit / offsetfilter status / channel

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.

GET/redemption/orders/{order_id}
scope read

Order detail: status, pickup code, pickup location and time, completion and refund information. The 13 states are in 8.1 State Machines.

Refund rules: when an order is cancelled or cannot be fulfilled (including all abnormal terminal states) → the frozen funds are fully refunded to the main-account XAUT balance, and any deducted service fee is refunded along with it. Restoration: if a 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 USDTonce 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.
GET/redemption/orders/{order_id}/events
scope read

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.

POST/redemption/orders/{order_id}/cancel
scope redeemidempotency Idempotency-Key required

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.

GET/withdrawal/fee
scope readparams asset (required), network (required)
// 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"
}
POST/withdrawals
scope withdrawidempotency Idempotency-Key required

Request body

FieldRequiredDescription
assetrequiredUSDT | XAUT
networkrequiredChain network
amountrequiredDecimal string
address_idrequiredMust point to an approved whitelisted address
fee_tokenrequiredFrom GET /withdrawal/fee; expired or inconsistent values → 422

Errors

codeTrigger
403 GF_ADDRESS_NOT_WHITELISTEDAddress is not on the approved whitelist (no withdrawal record is produced)
422 GF_FEE_TOKEN_INVALIDfee_token expired / tampered with / inconsistent with the request values
GET/withdrawals · GET/withdrawals/{withdrawal_id}
scope readpagination limit / offset

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).

Partner withdrawals reuse the existing withdrawal flow and state machine — the API merely adds one more request channel.
Authorization: API withdrawals do not require TOTP. The equivalent security controls = ① the 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}/approvewithdraw (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.

Attribution basis: GP balance and ledger are distributor-entity-level equity, not attributed by end user / sub-account. GP is neither offset in API redemption nor split across sub-accounts — if a distributor needs to allocate GP equity to specific end users, the distributor does so in its own ledger; GoldFin does not provide per-end-user GP attribution.
GET/goldpoints/balance
{ "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)
GET/goldpoints/ledger
pagination limit / offset

GP ledger: kind ∈ gp_earned | gp_redeemed, amount, at.

5.8 Referral (read-only)

GET/referrals/summary
{ "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)

typeTrigger
redemption.order.status_changedRedemption order status transition
deposit.creditedDeposit credited
withdrawal.status_changedWithdrawal 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
  }
}

Delivery headers

HeaderDescription
X-GF-Webhook-TimestampSend time in unix seconds (replay window ±300s)
X-GF-Webhook-Signaturev1=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-TypeDedup key / event type (redundant with the body, convenient for routing before parsing; the body covered by the signature is authoritative)
X-GF-Delivery-IdUnique per delivery (changes on retry, while event_id stays the same); report it to support when troubleshooting

Signature verification

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

scope readparams since_sequence (optional), type (optional), limit

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
}

How to configure

7. Sandbox Guide

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.

BehaviorSandboxProduction
Data visibilityFully isolated — a sandbox key never sees production data (including real web data), and vice versa; cross-environment resource lookups always return 404
Funds / fulfillmentEverything simulated: no on-chain activity, no real inventory movement, no physical deliveryReal funds and fulfillment
Balance sourcePOST /sandbox/deposits simulates a deposit (i.e. the test-funds faucet)Real on-chain deposits
Order status advancementExplicitly advanced by calling the test helper (see 7.0.1); no automatic placement/waitingReal business transitions
webhookIsomorphic delivery — each environment uses its own callback URL and secret, with identical event envelopes and signature verification
Idempotency / rate limitCounted independently per environment, with no cross-effect

7.0.1 Test helper endpoints (sandbox-only; 404 under the production domain)

EndpointPurpose
POST /sandbox/depositsSimulate a deposit credit (asset + amount), triggering deposit.credited
POST /sandbox/redemption/orders/{id}/advanceAdvance 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}/advanceAdvance 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

8.1 State Machines

Redemption order (RedemptionStatus, 13 states, reusing the existing FSM)

pending submitting confirmed payment_pending payment_sent verified delivery_requested awaiting_user_receipt completed ✓
Abnormal branches: booking_failed payment_failed delivery_failed cancelled (terminal states; funds handled per the refund rules, see the order refund field)

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:

StateFund actionPartner shouldShow to end customer
pendingFreeze the total payable XAUT (held unchanged, locked +, available −)Wait; cancellable in this window (full unfreeze)"Order submitted"
submittingFreeze maintainedWait (no longer cancellable)"Appointment processing"
confirmedFreeze maintainedPickup code becomes obtainable"Appointment confirmed, pickup available on {date}"
payment_pendingpayment_sentActual deduction (locked −, held −; the freeze is settled)No action; the pickup code remains availablesame as above
verified— (funds already deducted)Pickup code redeemed and hidden; the user has arrived at the store"Redeemed / delivering"
delivery_requestedawaiting_user_receiptWait for receipt confirmation (auto-completes after 24h)"Please confirm receipt"
completed— (terminal state)Reconcile and archive"Redemption complete"
cancelled / booking_failedFull 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_failedFull refund per the refund rulesContact 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)

pending processing completed ✓ | failed (terminal state; retry requires a new idempotency key)

Withdrawal (Withdrawal, 5 states)

approved submitted broadcasted completed ✓ | failed

API onboarding status (api_status, 4 states)

not_started sandbox_only production_active (production is enabled directly by operations, with no pending-review intermediate state)
Any enabled state ⇄ suspended (API paused) on restore, returns to the pre-freeze state (previous_api_status)

8.2 FAQ

QuestionAnswer
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