Skip to main content

Base URL

All endpoints are served from:
https://grail-stack-dev.onrender.com
The path prefix for every versioned endpoint is /v1. The GET /health probe lives at the root.

Authentication

Every protected endpoint requires an API key passed in the x-api-key header.
curl -H "x-api-key: grail_partner_<hex>" \
  https://grail-stack-dev.onrender.com/v1/denominations?country=PK
There is one partner scope (PARTNER) issued to your partner wallet via a challenge-response flow — see Request API Key Challenge and the Authentication & Setup guide. Two endpoints do not require auth: GET /health and the two endpoints under /v1/auth/* (which are how you obtain a key in the first place). The /v1/auth/* endpoints are rate-limited at 10 requests / minute / IP; exceeding returns 429 rate_limited.

Request & response conventions

  • Content type: JSON for both request and response bodies (Content-Type: application/json).
  • Field naming: all request and response fields use snake_case.
  • IDs: prefixed identifiers — gu_ (user), trd_ (trade), red_ (redemption), ch_ (challenge).
  • Amounts: human-readable decimals (e.g., usdc_amount: 100 means 100 USDC, not 100 microUSDC). The server converts to on-chain smallest units when building transactions.
  • Timestamps: ISO-8601 with timezone (e.g., 2026-04-17T10:30:00.000Z).
  • Solana signatures / partially-signed transactions: always base64.

Error responses

All errors return a JSON body of the form:
{
  "error": "invalid_request",
  "message": "usdc_amount must be a positive number",
  "field": "usdc_amount"
}
field is included only when the error is tied to a specific input field.

Common error codes

HTTPerrorWhen you’ll see it
400invalid_requestMissing or malformed fields in the request body
400invalid_walletA Solana address isn’t a valid pubkey
400invalid_signatureAuth signature verification failed (most often: sent base58 instead of base64)
400invalid_challengeAuth challenge not found or already used
400challenge_expiredAuth challenge older than 2 minutes
400invalid_denominationDenomination ID not found or inactive
400invalid_cityCity doesn’t match the denomination’s city
400kyc_level_insufficientUser KYC level is not full
400broadcast_failedSolana RPC rejected the transaction — the message carries the underlying reason
400cancellation_not_allowedRedemption has moved past submitted
401unauthorizedMissing or invalid x-api-key
403insufficient_scopeAPI key does not have the required scope
403partner_mismatchResource belongs to a different partner
403partner_suspendedPartner account is suspended
403user_suspendedUser account is suspended
404user_not_foundUnknown grail_user_id
404trade_not_foundTrade row doesn’t exist (often: indexer hasn’t written it yet — wait ~15s)
404redemption_not_foundRedemption is still at the internal quoted state, or doesn’t exist
409user_already_existswallet_address or partner_user_id already registered
429rate_limitedAuth endpoint exceeded 10 req / minute / IP
503pricing_unavailableGold price oracle unreachable or returned stale data

Transaction model

Buy, sell, and redemption flows all return a partially_signed_transaction (base64) in their quote response. You co-sign with the remaining required signer(s), then submit — either via the corresponding /submit endpoint (pure passthrough broadcast) or directly to any Solana RPC. The partially-signed transaction bakes in a Solana recentBlockhash that expires after ~60 seconds. If you take too long to sign and submit, you’ll get 400 broadcast_failed: Blockhash not found — re-quote and try again. Signers required:
FlowSigners
BuyGRAIL (server) + partner + user
SellGRAIL (server) + partner + user
RedemptionGRAIL (server) + user
GRAIL always partial-signs first and returns the serialized transaction. You add the remaining signatures on your side and submit.

Indexer-driven state

Trade and redemption rows are not written at submit time. An indexer watches Solana for the signed transactions and writes the authoritative row once the transaction confirms on-chain. Practical implication: right after you submit, GET /v1/trades/:id returns 404 for ~10–15 seconds. The same applies to redemptions — GET /v1/redemptions/:id returns 404 while the redemption is still at the internal quoted state, and only becomes visible once the indexer advances it to submitted.

API groups

Authentication

Mint a partner API key via challenge-response.

Partner

List and revoke your partner’s API keys.

Users

Register and look up end-users under your partner.

Denominations

Look up redeemable physical gold denominations by country.

Trades

Buy and sell $GOLD against USDC.

Redemptions

Convert $GOLD tokens into physical gold pickup.