# Create API Key
Source: https://docs.grail.oro.finance/api-reference/authentication/create-api-key
POST /v1/auth/api-key
Step 2 of the API key mint flow. Exchanges a signed challenge for a PARTNER scope API key.
## Overview
Submits the signature produced in [Request Challenge](/api-reference/authentication/request-challenge) to mint a new API key. On success, the raw API key is returned **exactly once** — store it somewhere safe. GRAIL only retains a hash.
The returned `api_key` is shown **only once**. It cannot be retrieved later — if lost, revoke it and mint a new one.
The signature must be a **base64-encoded** Ed25519 signature of the challenge `message` (the full string returned by Step 1). Do not send base58 — that produces `400 invalid_signature`. See the [Authentication & Setup guide](/guides/authentication-and-setup) for a signer snippet.
This endpoint is **rate-limited to 10 requests per minute per IP**. Exceeding the limit returns `429 rate_limited`.
## Request Body
The `challenge_id` returned from `POST /v1/auth/challenge`.
Base64-encoded Ed25519 signature of the challenge `message`, signed by the partner wallet's private key.
Human-readable label for this key (e.g., `"production integration"`, `"staging tests"`). Shown in the key list.
## Response
The raw API key. Format: `grail_partner_<64-hex>`. Pass this in the `x-api-key` header on all subsequent requests. **Shown only once.**
UUID of the key record. Use this to revoke the key later.
Always `"PARTNER"` for keys minted via this endpoint.
Echo of the `key_name` you supplied.
The partner wallet that signed the challenge.
ISO-8601 timestamp of key creation.
## Errors
| HTTP | `error` | When |
| ---- | ------------------- | --------------------------------------------------------------------------------- |
| 400 | `invalid_request` | Missing `challenge_id`, `signature`, or `key_name` |
| 400 | `invalid_challenge` | Challenge not found, or already used |
| 400 | `challenge_expired` | Challenge older than 2 minutes |
| 400 | `wallet_revoked` | Partner wallet's status is `revoked` |
| 400 | `invalid_signature` | Ed25519 verification failed — most often because the signature was sent as base58 |
| 429 | `rate_limited` | More than 10 requests in the last minute from this IP |
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/auth/api-key \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"signature": "r8JvAw...base64...Q==",
"key_name": "integration test"
}'
```
```json 201 Created theme={null}
{
"api_key": "grail_partner_7f3a...<64 hex chars total>",
"key_id": "0c9bd7e4-6b2e-4f3a-a9a7-1f6e5d4c3b2a",
"scope": "PARTNER",
"key_name": "integration test",
"wallet_address": "Fd31QxW7RRZwvMfNnhNaPvczJpMh7wyzBTWvtMA66wjN",
"created_at": "2026-04-19T12:30:45.123Z"
}
```
# Request API Key Challenge
Source: https://docs.grail.oro.finance/api-reference/authentication/request-challenge
POST /v1/auth/challenge
Step 1 of the API key mint flow. Returns a nonce message that the partner wallet must sign.
## Overview
Starts the challenge-response flow used to mint a partner API key. Submit the partner wallet address and partner ID; GRAIL returns a short-lived nonce message that must be signed with the partner wallet's private key and exchanged via [Create API Key](/api-reference/authentication/create-api-key).
The challenge expires **2 minutes** after it is issued. Complete the signing and exchange before then.
This endpoint is **rate-limited to 10 requests per minute per IP**. Exceeding the limit returns `429 rate_limited`.
## Request Body
The partner wallet address requesting the key. Must be an active wallet registered under the given partner.
The partner ID (UUID) the wallet belongs to. Provided by ORO when your partner is onboarded.
## Response
Unique identifier for this challenge, prefixed `ch_`. Embedded in the `message`.
The exact message to sign with the partner wallet. Format: `Sign this message to generate an API key for GRAIL: ch_`.
ISO-8601 timestamp. The challenge is unusable after this time.
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | ------------------------------------------------------------------------- |
| 400 | `invalid_request` | Missing `wallet_address` or `partner_id` |
| 400 | `invalid_wallet` | `wallet_address` is not a valid Solana pubkey |
| 404 | `wallet_not_found` | The wallet is not registered under this partner, or its status is revoked |
| 429 | `rate_limited` | More than 10 requests in the last minute from this IP |
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/auth/challenge \
-H "Content-Type: application/json" \
-d '{
"wallet_address": "Fd31QxW7RRZwvMfNnhNaPvczJpMh7wyzBTWvtMA66wjN",
"partner_id": "d8a8df53-36ca-4f4d-96ef-cf0a49d51e5d"
}'
```
```json 200 OK theme={null}
{
"challenge_id": "ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"message": "Sign this message to generate an API key for GRAIL: ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"expires_at": "2026-04-19T12:32:00.000Z"
}
```
# List Denominations
Source: https://docs.grail.oro.finance/api-reference/denominations/list-denominations
GET /v1/denominations
Returns the active physical-gold denominations available for redemption in a given country.
## Overview
Returns the catalog of physical-gold denominations that users can redeem for, filtered by country. Each entry includes its `id` (pass this to [Quote Redemption](/api-reference/redemptions/quote-redemption) as `denomination_id`), a human-readable label, weight in grams and troy ounces, and the city of pickup.
Only denominations with `active: true` are returned.
## Headers
A valid `PARTNER` scope key.
## Query Parameters
ISO-3166-1 alpha-2 country code (e.g., `PK`, `AE`, `SA`).
## Response
Denomination identifier. Pass to [Quote Redemption](/api-reference/redemptions/quote-redemption) as `denomination_id`. Example: `pk_tola_1`.
Human-readable label (e.g., `"1 Tola"`).
Weight in grams.
Weight in troy ounces.
City of pickup. Redemption quotes require the `city` field in the request to match this value (case-insensitive).
## Errors
| HTTP | `error` | When |
| ---- | ----------------- | -------------------------------- |
| 400 | `invalid_request` | `country` query param is missing |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
```bash cURL theme={null}
curl "https://grail-stack-dev.onrender.com/v1/denominations?country=PK" \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"denominations": [
{
"id": "pk_tola_1",
"label": "1 Tola",
"weight_g": 11.664,
"weight_troy_oz": 0.375,
"city": "karachi"
}
]
}
```
# Health Check
Source: https://docs.grail.oro.finance/api-reference/general/health
GET /health
Liveness probe. Returns a simple JSON body. No authentication required.
## Overview
A lightweight liveness probe. Does not touch the database or any downstream dependency. Safe to hit at high frequency for uptime monitoring.
## Request
No body, no query parameters, no authentication.
## Response
Always `"ok"` when the API process is up and responding.
```bash cURL theme={null}
curl https://grail-stack-dev.onrender.com/health
```
```json 200 OK theme={null}
{
"status": "ok"
}
```
# API Overview
Source: https://docs.grail.oro.finance/api-reference/overview
Shape, conventions, and authentication model for the GRAIL API.
## 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.
```bash theme={null}
curl -H "x-api-key: grail_partner_" \
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](/api-reference/authentication/request-challenge) and the [Authentication & Setup guide](/guides/authentication-and-setup).
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:
```json theme={null}
{
"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
| HTTP | `error` | When you'll see it |
| ---- | -------------------------- | ------------------------------------------------------------------------------- |
| 400 | `invalid_request` | Missing or malformed fields in the request body |
| 400 | `invalid_wallet` | A Solana address isn't a valid pubkey |
| 400 | `invalid_signature` | Auth signature verification failed (most often: sent base58 instead of base64) |
| 400 | `invalid_challenge` | Auth challenge not found or already used |
| 400 | `challenge_expired` | Auth challenge older than 2 minutes |
| 400 | `invalid_denomination` | Denomination ID not found or inactive |
| 400 | `invalid_city` | City doesn't match the denomination's city |
| 400 | `kyc_level_insufficient` | User KYC level is not `full` |
| 400 | `broadcast_failed` | Solana RPC rejected the transaction — the message carries the underlying reason |
| 400 | `cancellation_not_allowed` | Redemption has moved past `submitted` |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 403 | `insufficient_scope` | API key does not have the required scope |
| 403 | `partner_mismatch` | Resource belongs to a different partner |
| 403 | `partner_suspended` | Partner account is suspended |
| 403 | `user_suspended` | User account is suspended |
| 404 | `user_not_found` | Unknown `grail_user_id` |
| 404 | `trade_not_found` | Trade row doesn't exist (often: indexer hasn't written it yet — wait \~15s) |
| 404 | `redemption_not_found` | Redemption is still at the internal `quoted` state, or doesn't exist |
| 409 | `user_already_exists` | `wallet_address` or `partner_user_id` already registered |
| 429 | `rate_limited` | Auth endpoint exceeded 10 req / minute / IP |
| 503 | `pricing_unavailable` | Gold 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:**
| Flow | Signers |
| ---------- | ------------------------------- |
| Buy | GRAIL (server) + partner + user |
| Sell | GRAIL (server) + partner + user |
| Redemption | GRAIL (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
Mint a partner API key via challenge-response.
List and revoke your partner's API keys.
Register and look up end-users under your partner.
Look up redeemable physical gold denominations by country.
Buy and sell `$GOLD` against USDC.
Convert `$GOLD` tokens into physical gold pickup.
# List API Keys
Source: https://docs.grail.oro.finance/api-reference/partner/list-api-keys
GET /v1/partner/api-keys
Returns metadata for every API key minted against the authenticated partner's wallet.
## Overview
Lists all API keys (active and revoked) minted against the authenticated partner's wallet. Only metadata is returned — raw API key values are **never** retrievable after creation.
## Headers
A valid `PARTNER` scope key for the partner whose keys you want to list.
## Response
UUID of the key record. Pass to [Revoke API Key](/api-reference/partner/revoke-api-key) to revoke.
Human-readable label supplied at creation.
Scope granted to the key. Always `"PARTNER"` for partner-minted keys.
`"active"` or `"revoked"`.
Partner wallet that signed the challenge when the key was minted.
ISO-8601 timestamp of the most recent request authenticated with this key, or `null` if the key has never been used.
ISO-8601 timestamp of key creation.
ISO-8601 timestamp of revocation, or `null` if the key is still active.
## Errors
| HTTP | `error` | When |
| ---- | -------------- | ------------------------------ |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
```bash cURL theme={null}
curl https://grail-stack-dev.onrender.com/v1/partner/api-keys \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"api_keys": [
{
"key_id": "0c9bd7e4-6b2e-4f3a-a9a7-1f6e5d4c3b2a",
"key_name": "integration test",
"scope": "PARTNER",
"status": "active",
"wallet_address": "Fd31QxW7RRZwvMfNnhNaPvczJpMh7wyzBTWvtMA66wjN",
"last_used_at": "2026-04-19T12:45:10.002Z",
"created_at": "2026-04-19T12:30:45.123Z",
"revoked_at": null
}
]
}
```
# Revoke API Key
Source: https://docs.grail.oro.finance/api-reference/partner/revoke-api-key
POST /v1/partner/api-keys/{key_id}/revoke
Permanently revokes an API key. Revocation is immediate and irreversible.
## Overview
Revokes an API key so that no further requests can authenticate with it. Revocation is permanent and takes effect immediately — the next request using the revoked key returns `401 unauthorized`.
Revocation is irreversible. To restore access, mint a new key via the [challenge-response flow](/api-reference/authentication/request-challenge).
## Headers
A valid `PARTNER` scope key for the partner that owns the key being revoked.
## Path Parameters
UUID of the key to revoke. Available from [List API Keys](/api-reference/partner/list-api-keys).
## Response
UUID of the revoked key.
Always `"revoked"` on success.
ISO-8601 timestamp of the revocation.
## Errors
| HTTP | `error` | When |
| ---- | ----------------- | ---------------------------------------------------------- |
| 400 | `already_revoked` | The key is already in `revoked` status |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 404 | `key_not_found` | `key_id` does not exist, or belongs to a different partner |
```bash cURL theme={null}
curl -X POST \
https://grail-stack-dev.onrender.com/v1/partner/api-keys/0c9bd7e4-6b2e-4f3a-a9a7-1f6e5d4c3b2a/revoke \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"key_id": "0c9bd7e4-6b2e-4f3a-a9a7-1f6e5d4c3b2a",
"status": "revoked",
"revoked_at": "2026-04-19T13:00:00.000Z"
}
```
# Cancel Redemption
Source: https://docs.grail.oro.finance/api-reference/redemptions/cancel-redemption
POST /v1/redemptions/{id}/cancel
Requests cancellation of a submitted redemption. Only valid before ORO starts physical preparation.
## Overview
Flags a redemption for cancellation. **Only valid when the redemption is at `submitted`.** Once ORO advances it to `preparing`, cancel is no longer allowed — any refund would be handled out-of-band.
On success, the redemption transitions to `cancellation_requested`. ORO's admin subsequently advances it to `cancelled` after confirming no physical gold has been committed.
The user has already transferred their `$GOLD` to escrow at `submitted`. Cancellation after that point is a coordination question — ORO does not automatically return tokens on cancel. Reach out separately if refunds are needed.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Redemption identifier (prefixed `red_`).
## Request Body
Free-form string explaining the cancel. Stored on the redemption row as `cancellation_reason`.
## Response
Echo of the path parameter.
Always `"cancellation_requested"` on success.
## Errors
| HTTP | `error` | When |
| ---- | -------------------------- | ----------------------------------------------------------------------------------- |
| 400 | `cancellation_not_allowed` | Redemption status is not `submitted` (e.g., already `preparing`, `cancelled`, etc.) |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 403 | `partner_mismatch` | Redemption belongs to a different partner |
| 404 | `redemption_not_found` | No redemption row at or past `submitted` for this id |
```bash cURL theme={null}
curl -X POST \
https://grail-stack-dev.onrender.com/v1/redemptions/red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/cancel \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{"reason":"user changed mind"}'
```
```json 200 OK theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "cancellation_requested"
}
```
# Get Redemption
Source: https://docs.grail.oro.finance/api-reference/redemptions/get-redemption
GET /v1/redemptions/{id}
Fetches a single redemption by ID. Returns 404 while the redemption is at the internal quoted state.
## Overview
Returns the redemption record for a given `redemption_id`. Partner-scoped.
While the redemption is still at the internal `quoted` status (i.e., before the user's transaction has been confirmed on-chain and picked up by the indexer), this endpoint returns `404 redemption_not_found`. The record becomes visible once status advances to `submitted`.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Redemption identifier (prefixed `red_`).
## Response
Redemption identifier.
Current status. One of: `submitted`, `preparing`, `ready`, `collected`, `cancellation_requested`, `cancelled`, `failed`.
Denomination label, e.g., `"1 Tola"`.
Weight of the denomination in grams.
City of pickup.
Gold spot price at quote time.
USD value of the gold at spot.
Partner redemption fee in USD.
`gold_value_usd + fee_usd`.
`$GOLD` tokens required, formatted to 6 decimal places.
Solana transaction signature (base58) of the on-chain GOLD transfer, or `null`.
If the redemption was cancelled, the reason supplied at cancel time; otherwise `null`.
ISO-8601 timestamp of row creation.
ISO-8601 timestamp of most recent status update.
## Status lifecycle
```
quoted (internal)
└─► submitted ← indexer writes after on-chain confirmation
├─► preparing ── ready ── collected (ORO admin advances)
└─► cancellation_requested ── cancelled
```
`quoted` is never exposed. `collected` and `cancelled` are terminal.
## Errors
| HTTP | `error` | When |
| ---- | ---------------------- | ---------------------------------------------------- |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 403 | `partner_mismatch` | Redemption belongs to a different partner |
| 404 | `redemption_not_found` | No redemption row at or past `submitted` for this id |
```bash cURL theme={null}
curl https://grail-stack-dev.onrender.com/v1/redemptions/red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "submitted",
"denomination": "1 Tola",
"weight_g": 11.664,
"city": "karachi",
"quote": {
"spot_price_usd": 4872.84,
"gold_value_usd": 1827.34,
"fee_usd": 0,
"total_usd": 1827.34,
"tokens_required": "0.375006"
},
"submitted_tx_hash": "4ABC...base58",
"cancellation_reason": null,
"created_at": "2026-04-17T11:00:00.000Z",
"updated_at": "2026-04-17T11:00:15.000Z"
}
```
# List Redemptions
Source: https://docs.grail.oro.finance/api-reference/redemptions/list-redemptions
GET /v1/redemptions
Lists redemptions for the authenticated partner. Excludes the internal quoted state.
## Overview
Lists redemption rows for the authenticated partner, ordered by `created_at` descending. The internal `quoted` state is **always excluded** — only redemptions at `submitted` or later are returned.
## Headers
A valid `PARTNER` scope key.
## Query Parameters
Filter to a single user. If the user doesn't exist, returns an empty list.
Filter by status: `submitted`, `preparing`, `ready`, `collected`, `cancellation_requested`, `cancelled`, or `failed`.
Filter by pickup city.
## Response
Array of redemption objects — same shape as [Get Redemption](/api-reference/redemptions/get-redemption).
## Errors
| HTTP | `error` | When |
| ---- | -------------- | ------------------------------ |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
```bash cURL theme={null}
curl "https://grail-stack-dev.onrender.com/v1/redemptions?status=submitted" \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"redemptions": [
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "submitted",
"denomination": "1 Tola",
"weight_g": 11.664,
"city": "karachi",
"quote": {
"spot_price_usd": 4872.84,
"gold_value_usd": 1827.34,
"fee_usd": 0,
"total_usd": 1827.34,
"tokens_required": "0.375006"
},
"submitted_tx_hash": "4ABC...base58",
"created_at": "2026-04-17T11:00:00.000Z",
"updated_at": "2026-04-17T11:00:15.000Z"
}
]
}
```
# Quote Redemption
Source: https://docs.grail.oro.finance/api-reference/redemptions/quote-redemption
POST /v1/redemptions
Quotes a physical-gold redemption. Returns a partially-signed transaction for the user to co-sign.
## Overview
Quotes a redemption of `$GOLD` tokens for a physical-gold denomination and returns the transaction the user must sign to transfer the tokens into GRAIL's escrow.
Unlike trades, a redemption **does** write a row at quote time — with status `quoted` (internal-only, not returned via [Get Redemption](/api-reference/redemptions/get-redemption) until the indexer advances it to `submitted` after the user's transaction confirms on-chain).
Only the **user wallet** needs to co-sign — there is no partner signature on redemptions. The transaction transfers `tokens_required` `$GOLD` from the user's wallet to GRAIL's escrow.
The partial-signed transaction expires in **\~60 seconds** (Solana `recentBlockhash` TTL). Re-quote if you miss the window.
## Headers
A valid `PARTNER` scope key.
## Request Body
GRAIL user ID (prefixed `gu_`). User must belong to the authenticated partner, be `active`, and have `kyc_level: "full"`.
Denomination ID from [List Denominations](/api-reference/denominations/list-denominations), e.g., `"pk_tola_1"`.
City of pickup. Must match the denomination's registered `city` (case-insensitive).
## Response
Redemption identifier, prefixed `red_`. Use with [Submit Redemption](/api-reference/redemptions/submit-redemption), [Get Redemption](/api-reference/redemptions/get-redemption), and [Cancel Redemption](/api-reference/redemptions/cancel-redemption).
Denomination label, e.g., `"1 Tola"`.
Weight of the denomination in grams.
Gold spot price in USD per troy ounce at quote time.
USD value of the gold at spot.
Partner redemption fee in USD (partner's `fee_percentage` × `gold_value_usd`).
`gold_value_usd + fee_usd`. The USD value of `tokens_required`.
`$GOLD` tokens the user must transfer, formatted to 6 decimal places. **This is a token amount, not USD** — do not confuse with `total_usd`.
Base64-encoded Solana transaction, signed by GRAIL. Co-sign with the **user wallet only** (no partner signature on redemptions).
## Errors
| HTTP | `error` | When |
| ---- | ------------------------ | ------------------------------------------------------- |
| 400 | `invalid_request` | Missing `grail_user_id`, `denomination_id`, or `city` |
| 400 | `invalid_denomination` | `denomination_id` not found or inactive |
| 400 | `invalid_city` | `city` doesn't match the denomination's registered city |
| 400 | `kyc_level_insufficient` | User's KYC level is not `full` |
| 403 | `partner_mismatch` | User belongs to a different partner |
| 403 | `user_suspended` | User status is `suspended` |
| 404 | `user_not_found` | No user with the given `grail_user_id` |
| 503 | `pricing_unavailable` | Gold price oracle unreachable or returned stale data |
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/redemptions \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"denomination_id": "pk_tola_1",
"city": "karachi"
}'
```
```json 201 Created theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"quote": {
"denomination": "1 Tola",
"weight_g": 11.664,
"spot_price_usd": 4872.84,
"gold_value_usd": 1827.34,
"fee_usd": 0,
"total_usd": 1827.34,
"tokens_required": "0.375006"
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
# Submit Redemption
Source: https://docs.grail.oro.finance/api-reference/redemptions/submit-redemption
POST /v1/redemptions/{id}/submit
Broadcasts the user-signed redemption transaction to Solana. Pure passthrough.
## Overview
Broadcasts the fully-signed redemption transaction and returns the on-chain signature. Pure passthrough — no database write. The indexer transitions the redemption from `quoted` to `submitted` (or `failed` on revert) once the transaction is confirmed on-chain.
You may also broadcast directly to any Solana RPC. The indexer will pick up the transaction regardless.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Redemption identifier (prefixed `red_`) returned by [Quote Redemption](/api-reference/redemptions/quote-redemption).
## Request Body
Base64-encoded, fully-signed Solana transaction (GRAIL's partial sig + user).
## Response
Echo of the path parameter.
Solana transaction signature (base58).
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | -------------------------------------------------------- |
| 400 | `invalid_request` | Missing or non-string `signed_tx` |
| 400 | `broadcast_failed` | Solana RPC rejected the transaction — message has reason |
```bash cURL theme={null}
curl -X POST \
https://grail-stack-dev.onrender.com/v1/redemptions/red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/submit \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"signed_tx": "AQAAAAABAAEC......"
}'
```
```json 200 OK theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"tx_hash": "4ABC...base58"
}
```
# Get Trade
Source: https://docs.grail.oro.finance/api-reference/trades/get-trade
GET /v1/trades/{trade_id}
Fetches a single trade by ID. Returns 404 until the indexer has written the row.
## Overview
Returns the `Trade` record for a `trade_id`. Because GRAIL's indexer is the sole writer of trade rows, this endpoint returns **404 until the transaction has confirmed on-chain and been indexed** (typically 10–15 seconds on devnet after submit).
Partner-scoped — returns `403 partner_mismatch` if the trade belongs to a different partner.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Trade identifier (prefixed `trd_`).
## Response
Trade identifier.
`"buy"` or `"sell"`.
Final status written by the indexer: `"confirmed"` or `"failed"`.
USDC amount (input on buys, output on sells). For `failed` trades, the value is from the transaction's memo; the opposing side and fees are `0` because the on-chain program never computed them.
`$GOLD` amount (output on buys, input on sells). Same caveat on `failed` trades.
Gold price at the time the on-chain program executed.
Fee rate applied, in basis points.
Fee in USDC.
Solana transaction signature (base58).
ISO-8601 timestamp of row creation (when the indexer wrote the trade).
ISO-8601 timestamp of most recent update.
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | ---------------------------------------------------------------------- |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 403 | `partner_mismatch` | Trade belongs to a different partner |
| 404 | `trade_not_found` | No trade row for this `trade_id` (often: indexer hasn't caught up yet) |
```bash cURL theme={null}
curl https://grail-stack-dev.onrender.com/v1/trades/trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9 \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"side": "buy",
"status": "confirmed",
"usdc_amount": 100,
"gold_amount": 0.020528,
"price_per_troy_oz": 4872.84,
"fee_bps": 50,
"fee_usd": 0.50,
"submitted_tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58",
"created_at": "2026-04-17T10:18:30.000Z",
"updated_at": "2026-04-17T10:18:30.000Z"
}
```
# List Trades
Source: https://docs.grail.oro.finance/api-reference/trades/list-trades
GET /v1/trades
Lists trades for the authenticated partner, with optional filters.
## Overview
Lists trade rows for the authenticated partner. All filters are optional; omit them to list everything. Results are ordered by `created_at` descending.
Only trades that the indexer has written are returned. Trades that have just been submitted but not yet confirmed on-chain do not appear until the indexer processes them.
## Headers
A valid `PARTNER` scope key.
## Query Parameters
Filter to a single user. If the user doesn't exist, returns an empty list (not a 404).
Filter by side: `"buy"` or `"sell"`.
Filter by status: `"confirmed"` or `"failed"`.
## Response
Array of trade objects — same shape as [Get Trade](/api-reference/trades/get-trade).
## Errors
| HTTP | `error` | When |
| ---- | -------------- | ------------------------------ |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
```bash cURL theme={null}
curl "https://grail-stack-dev.onrender.com/v1/trades?side=buy&status=confirmed" \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"trades": [
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"side": "buy",
"status": "confirmed",
"usdc_amount": 100,
"gold_amount": 0.020528,
"price_per_troy_oz": 4872.84,
"fee_bps": 50,
"fee_usd": 0.50,
"submitted_tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58",
"created_at": "2026-04-17T10:18:30.000Z",
"updated_at": "2026-04-17T10:18:30.000Z"
}
]
}
```
# Quote Buy
Source: https://docs.grail.oro.finance/api-reference/trades/quote-buy
POST /v1/buy
Stateless quote for buying $GOLD with USDC. Returns a partially-signed Solana transaction to co-sign and submit.
## Overview
Builds a buy transaction (USDC → `$GOLD`) and returns it partially-signed by GRAIL. The client must co-sign with the **partner wallet** and the **user wallet**, then either call [Submit Buy](/api-reference/trades/submit-buy) or broadcast directly to a Solana RPC.
This endpoint is stateless — no database row is created at quote time. The `Trade` row is written by the indexer once the transaction confirms on-chain (`confirmed` or `failed`).
The partial-signed transaction expires in **\~60 seconds** (Solana `recentBlockhash` TTL). If you take too long to co-sign and submit, you'll get `broadcast_failed: Blockhash not found`. Re-quote.
## Headers
A valid `PARTNER` scope key.
## Request Body
GRAIL user ID (prefixed `gu_`). User must belong to the authenticated partner, be `active`, and have `kyc_level: "full"`.
USDC the user will spend, in human decimal (e.g., `100` = 100 USDC). Must be positive.
Slippage tolerance in basis points. Default `50` (0.5%). Ignored if `min_gold_out` is provided.
Absolute minimum `$GOLD` to receive (human decimal). If supplied, overrides `slippage_bps`. If omitted, computed as `quoted_gold * (10000 - slippage_bps) / 10000`.
## Response
Trade identifier, prefixed `trd_`. Use with [Submit Buy](/api-reference/trades/submit-buy) and [Get Trade](/api-reference/trades/get-trade).
Always `"buy"`.
Input USDC (echo of request).
Expected `$GOLD` output at quote-time spot price (pre-slippage).
Gold spot price in USD per troy ounce at quote time.
Fee rate applied in basis points (derived from the partner's IntegratorV2 on-chain config).
Fee in USDC deducted from the input before swap.
Minimum `$GOLD` the tx will accept — slippage floor.
Base64-encoded Solana transaction, already signed by GRAIL. Co-sign with partner + user and submit.
## Errors
| HTTP | `error` | When |
| ---- | ------------------------ | -------------------------------------------------------------- |
| 400 | `invalid_request` | Missing or non-positive `usdc_amount`, missing `grail_user_id` |
| 400 | `kyc_level_insufficient` | User's KYC level is not `full` |
| 400 | `onchain_config_missing` | Partner's on-chain config hasn't been set up yet. Contact ORO. |
| 400 | `wallet_missing` | Partner has no registered wallet |
| 403 | `partner_mismatch` | User belongs to a different partner |
| 403 | `user_suspended` | User status is `suspended` |
| 404 | `user_not_found` | No user with the given `grail_user_id` |
| 503 | `pricing_unavailable` | Gold price oracle unreachable or returned stale data |
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/buy \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"usdc_amount": 100,
"slippage_bps": 50
}'
```
```json 200 OK theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"side": "buy",
"quote": {
"usdc_amount": 100,
"gold_amount": 0.0205,
"price_per_troy_oz": 4872.84,
"fee_bps": 50,
"fee_usd": 0.50,
"min_gold_out": 0.0204
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
# Quote Sell
Source: https://docs.grail.oro.finance/api-reference/trades/quote-sell
POST /v1/sell
Stateless quote for selling $GOLD for USDC. Returns a partially-signed Solana transaction to co-sign and submit.
## Overview
Builds a sell transaction (`$GOLD` → USDC) and returns it partially-signed by GRAIL. The client must co-sign with the **partner wallet** and the **user wallet**, then either call [Submit Sell](/api-reference/trades/submit-sell) or broadcast directly to a Solana RPC.
Stateless — no database row is created at quote time. The `Trade` row is written by the indexer once the transaction confirms on-chain.
The partial-signed transaction expires in **\~60 seconds** (Solana `recentBlockhash` TTL). Re-quote if you miss the window.
## Headers
A valid `PARTNER` scope key.
## Request Body
GRAIL user ID (prefixed `gu_`). User must belong to the authenticated partner, be `active`, and have `kyc_level: "full"`.
`$GOLD` the user will sell, in human decimal. Must be positive.
Slippage tolerance in basis points. Default `50` (0.5%). Ignored if `min_usdc_out` is provided.
Absolute minimum USDC to receive (human decimal). If supplied, overrides `slippage_bps`.
## Response
Trade identifier, prefixed `trd_`.
Always `"sell"`.
Input `$GOLD` (echo of request).
Expected USDC output at quote-time spot price (post-fee, pre-slippage).
Gold spot price in USD per troy ounce at quote time.
Fee rate applied in basis points.
Fee in USDC deducted from the output.
Minimum USDC the tx will accept — slippage floor.
Base64-encoded Solana transaction, signed by GRAIL. Co-sign with partner + user and submit.
## Errors
Same error set as [Quote Buy](/api-reference/trades/quote-buy): `invalid_request`, `kyc_level_insufficient`, `onchain_config_missing`, `wallet_missing`, `partner_mismatch`, `user_suspended`, `user_not_found`, `pricing_unavailable`.
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/sell \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"gold_amount": 0.01,
"slippage_bps": 50
}'
```
```json 200 OK theme={null}
{
"trade_id": "trd_8b21d7f3-2a4c-4b5e-9d1f-3e8a7c6b5d4e",
"side": "sell",
"quote": {
"gold_amount": 0.01,
"usdc_amount": 48.49,
"price_per_troy_oz": 4872.84,
"fee_bps": 75,
"fee_usd": 0.37,
"min_usdc_out": 48.25
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
# Submit Buy
Source: https://docs.grail.oro.finance/api-reference/trades/submit-buy
POST /v1/buy/{trade_id}/submit
Broadcasts the fully-signed buy transaction to Solana. Pure passthrough — no database write.
## Overview
Broadcasts the fully-signed buy transaction to Solana and returns the on-chain signature. This is a **pure passthrough** — GRAIL calls `sendRawTransaction` and returns the signature. **No database row is written here.**
The `Trade` row is written asynchronously by the indexer after the transaction confirms on-chain, typically 10–15 seconds on devnet. Until then, [Get Trade](/api-reference/trades/get-trade) returns `404`.
You may also broadcast the signed transaction directly to any Solana RPC without calling this endpoint. The indexer picks up the confirmed transaction regardless of who broadcasts it.
GRAIL's submit endpoint runs Solana's pre-flight simulation. If you want a transaction to actually land and revert on-chain (e.g., for failure-path testing), broadcast yourself with `skipPreflight: true`.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Trade identifier (prefixed `trd_`) returned by [Quote Buy](/api-reference/trades/quote-buy).
## Request Body
Base64-encoded, fully-signed Solana transaction. Starts from the `partially_signed_transaction` returned by the quote, with partner and user signatures added.
## Response
Echo of the path parameter.
Solana transaction signature (base58). The on-chain identifier of the broadcasted transaction.
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 400 | `invalid_request` | Missing or non-string `signed_tx` |
| 400 | `broadcast_failed` | Solana RPC rejected the transaction. Message carries the reason (expired blockhash, slippage, insufficient funds, missing signature, etc.) |
```bash cURL theme={null}
curl -X POST \
https://grail-stack-dev.onrender.com/v1/buy/trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9/submit \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"signed_tx": "AQAAAAABAAEC......"
}'
```
```json 200 OK theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58"
}
```
# Submit Sell
Source: https://docs.grail.oro.finance/api-reference/trades/submit-sell
POST /v1/sell/{trade_id}/submit
Broadcasts the fully-signed sell transaction to Solana. Pure passthrough — no database write.
## Overview
Broadcasts the fully-signed sell transaction and returns the on-chain signature. Semantics are identical to [Submit Buy](/api-reference/trades/submit-buy) — pure passthrough, no database write; the indexer writes the `Trade` row asynchronously after on-chain confirmation.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
Trade identifier (prefixed `trd_`) returned by [Quote Sell](/api-reference/trades/quote-sell).
## Request Body
Base64-encoded, fully-signed Solana transaction (GRAIL's partial sig + partner + user).
## Response
Echo of the path parameter.
Solana transaction signature (base58).
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | -------------------------------------------------------- |
| 400 | `invalid_request` | Missing or non-string `signed_tx` |
| 400 | `broadcast_failed` | Solana RPC rejected the transaction — message has reason |
```bash cURL theme={null}
curl -X POST \
https://grail-stack-dev.onrender.com/v1/sell/trd_8b21d7f3-2a4c-4b5e-9d1f-3e8a7c6b5d4e/submit \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"signed_tx": "AQAAAAABAAEC......"
}'
```
```json 200 OK theme={null}
{
"trade_id": "trd_8b21d7f3-2a4c-4b5e-9d1f-3e8a7c6b5d4e",
"tx_hash": "3xJk9Pq2n...base58"
}
```
# Create User
Source: https://docs.grail.oro.finance/api-reference/users/create-user
POST /v1/users
Registers an end-user under the authenticated partner. KYC is flexible JSON — core fields are required, provider-specific data lives in kyc_data.
## Overview
Registers an end-user under the authenticated partner and stores their KYC record. The user must have `kyc_level: "full"` before they can quote trades or redemptions.
Core KYC fields (`country`, `full_name`, `kyc_provider`, `kyc_level`, `kyc_verified_at`) are structured columns; provider-specific data goes in the free-form `kyc_data` object. If `kyc_data` includes `id_type` and `id_number`, GRAIL validates the `id_number` against the regex registered for that country/ID-type pair (advisory — absent regex means no validation is enforced).
## Headers
A valid `PARTNER` scope key.
## Request Body
Your internal identifier for this user. Unique within your partner (two users cannot share the same `user_id`). Stored as `partner_user_id` in GRAIL.
The user's Solana wallet address. Must be a valid pubkey, and globally unique across all GRAIL users.
The user's KYC record.
ISO-3166-1 alpha-2 country code. Must match an active country in GRAIL's country list (e.g., `PK`, `AE`, `SA`).
Full legal name. 1–255 characters.
Label identifying the KYC provider you used (e.g., `"sumsub"`, `"manual"`). Free-form string — stored verbatim.
Must be `"full"`. Partial or basic KYC is rejected.
ISO-8601 timestamp of when KYC was completed on your side.
Optional free-form JSON for provider-specific fields. If `id_type` and `id_number` are included, the server performs advisory validation of `id_number` against the country's registered regex.
## Response
GRAIL's identifier for the user, prefixed `gu_`. Use this in all subsequent trade and redemption calls.
Echo of your `user_id` (the `partner_user_id`).
Echo of `wallet_address`.
ISO-8601 timestamp of user creation.
## Errors
| HTTP | `error` | When |
| ---- | ------------------------ | ----------------------------------------------------------------------------------------------------- |
| 400 | `invalid_request` | Missing required fields, bad timestamp, bad `full_name`, `kyc_data.id_number` doesn't match the regex |
| 400 | `invalid_wallet` | `wallet_address` is not a valid Solana pubkey |
| 400 | `kyc_level_insufficient` | `kyc.kyc_level` is not `"full"` |
| 409 | `user_already_exists` | `wallet_address` is already registered, or `user_id` is already registered for this partner |
```bash cURL theme={null}
curl -X POST https://grail-stack-dev.onrender.com/v1/users \
-H "x-api-key: grail_partner_" \
-H "Content-Type: application/json" \
-d '{
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"kyc": {
"country": "PK",
"full_name": "Alice Example",
"kyc_provider": "manual",
"kyc_level": "full",
"kyc_verified_at": "2026-04-17T00:00:00Z",
"kyc_data": {
"id_type": "CNIC",
"id_number": "12345-1234567-1"
}
}
}'
```
```json 201 Created theme={null}
{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"created_at": "2026-04-17T10:15:30.000Z"
}
```
# Get User
Source: https://docs.grail.oro.finance/api-reference/users/get-user
GET /v1/users/{grail_user_id}
Fetches a user's profile and KYC record. Only users belonging to the authenticated partner are accessible.
## Overview
Returns the user's profile plus their KYC record. Partner-scoped — if the `grail_user_id` belongs to a different partner, the request is rejected with `403 partner_mismatch`.
## Headers
A valid `PARTNER` scope key.
## Path Parameters
GRAIL's identifier for the user, prefixed `gu_`. Returned from [Create User](/api-reference/users/create-user).
## Response
GRAIL's user identifier.
Your internal identifier (the `partner_user_id` supplied at creation time).
The user's Solana wallet address.
`"active"` or `"suspended"`. Suspended users cannot quote trades or redemptions.
ISO-8601 timestamp of user creation.
The user's KYC record. Omitted if no KYC record exists (shouldn't happen for users created via [Create User](/api-reference/users/create-user)).
ISO-3166-1 alpha-2 country code.
Full legal name as supplied at creation.
`"full"` for users able to trade / redeem.
Label of the KYC provider.
ISO-8601 timestamp of when KYC was verified.
Free-form JSON with provider-specific fields, as supplied at creation.
## Errors
| HTTP | `error` | When |
| ---- | ------------------ | -------------------------------------- |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
| 403 | `partner_mismatch` | User belongs to a different partner |
| 404 | `user_not_found` | No user with the given `grail_user_id` |
```bash cURL theme={null}
curl https://grail-stack-dev.onrender.com/v1/users/gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3 \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"status": "active",
"created_at": "2026-04-17T10:15:30.000Z",
"kyc": {
"country": "PK",
"full_name": "Alice Example",
"kyc_level": "full",
"kyc_provider": "manual",
"kyc_verified_at": "2026-04-17T00:00:00.000Z",
"kyc_data": {
"id_type": "CNIC",
"id_number": "12345-1234567-1"
}
}
}
```
# List Users
Source: https://docs.grail.oro.finance/api-reference/users/list-users
GET /v1/users
Lists users for the authenticated partner, with optional filters.
## Overview
Returns a partner-scoped list of users in reverse-chronological order (newest first). All filters are optional; omit them to list every user belonging to the partner.
The list response is intentionally minimal — it does not include KYC detail. For full KYC data on a specific user, call [Get User](/api-reference/users/get-user).
## Headers
A valid `PARTNER` scope key.
## Query Parameters
Filter by user status: `"active"` or `"suspended"`.
Reverse-lookup by your own `user_id` (the `partner_user_id` supplied at creation). Since this pair is unique per partner, the response contains at most one user.
## Response
Array of user objects. Each object:
GRAIL's identifier for the user, prefixed `gu_`.
Your internal identifier (the `partner_user_id` supplied at creation).
The user's Solana wallet address.
`"active"` or `"suspended"`. Suspended users cannot quote trades or redemptions.
ISO-8601 timestamp of user creation.
## Errors
| HTTP | `error` | When |
| ---- | -------------- | ------------------------------ |
| 401 | `unauthorized` | Missing or invalid `x-api-key` |
```bash cURL theme={null}
curl "https://grail-stack-dev.onrender.com/v1/users?status=active" \
-H "x-api-key: grail_partner_"
```
```json 200 OK theme={null}
{
"users": [
{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"status": "active",
"created_at": "2026-04-17T10:15:30.000Z"
}
]
}
```
# Conclusion
Source: https://docs.grail.oro.finance/conclusion
GRAIL with its dual Custodial and Self-Custody models, offers a secure, efficient, and highly flexible framework for centralized entities to provide on-chain gold ownership. By defining a Distribution Partner's `kind` at setup, the system adapts to support either partner-managed user assets (Custodial) or user-managed assets in their own Web3 wallets (Self-Custody).
Key features like central PDAs for KYC and partner operations, compressed user accounts for data management, and a robust authority structure are tailored to each model. This strikes an optimal balance between decentralized security, user empowerment, and centralized operational efficiency.
The open-source nature and clear pathways for future expansions like multi-asset support and DeFi integrations make it a versatile, partner-friendly, and user-centric solution for the modern financial landscape.
# Contact
Source: https://docs.grail.oro.finance/contact
For more information, please contact the Oro team at [support@orogold.app](mailto:support@orogold.app)
Be a part of the community
Get notified about new features
# Why Partner with GRAIL
Source: https://docs.grail.oro.finance/grail-overview/advantages-for-dstribution-partners
## Flexibility
## Serve More Users
Offer both custodial and self-custody options from the same integration. Some users want managed simplicity; others want direct control. GRAIL lets you serve both without building two systems.
### Product Optionality
Start with basic gold purchases. Expand to savings products, yield offerings, credit services, or rewards programs as your users mature. The infrastructure scales with your roadmap.
## Reduced Complexity
### No Blockchain Ops
You don't need to run nodes, manage validators, or understand blockchain internals. The API abstracts complexity—you make REST calls, we handle on-chain execution.
### No Commodity Expertise
Gold sourcing, custody, auditing, and compliance are handled by Oro. You integrate an API, not a commodities operation.
### Clear Integration Paths
Separate endpoints and flows for Custodial vs Self-Custody. Pick your model, follow the docs, ship.
## Security & Trust
### Program-Enforced Protection
Custodial user funds are protected at the smart contract level. Your operational mistakes can't accidentally drain user balances—the program won't allow it.
### User Empowerment (Self-Custody)
Users hold their own keys and control their own assets. You provide the platform and KYC; they maintain sovereignty. Trust is distributed, not concentrated.
### Immutable Audit Trail
Every transaction is on-chain. Users can verify their holdings independently. Regulators can audit without relying solely on your internal records.
## Competitive Edge
### First-Mover Features
Gold ownership is underrepresented in fintech. Most apps offer stocks, crypto, maybe cash accounts. Gold is differentiated—and GRAIL makes it easy to add.
### Transparent Ownership
Unlike gold ETFs or pool accounts, GRAIL provides verifiable on-chain ownership. Users can see their gold exists. That transparency builds trust.
### DeFi Optionality (Self-Custody)
Self-custody gold tokens are portable. Users can take their gold into DeFi—lending, liquidity provision, collateral. You become a gateway to a broader ecosystem.
## Cost Efficiency
### Compressed Accounts
On-chain storage costs are reduced by \~1000x using compressed accounts. You can onboard millions of users without prohibitive blockchain costs.
### Efficient Liquidity Management
Central vaults (Custodial) and operational vaults (Self-Custody) let you manage liquidity in one place rather than fragmenting across user accounts.
### No Infrastructure Overhead
No servers to run, no custody solutions to build, no compliance frameworks to design from scratch. API calls have predictable costs.
# API Support
Source: https://docs.grail.oro.finance/grail-overview/api-support
GRAIL will provide comprehensive APIs for Distribution Partners to integrate with their existing systems, supporting both Custodial and Self-Custody models:
### User Management API
* Create users with KYC-hashed identifiers.
* For **Self-Custody**: Link user's KYC hash with their public Web3 wallet address, potentially including a wallet ownership verification step.
* Check user status, KYC linkage, and (for Custodial model) gold balances managed by the program.
### Transaction API
* **Custodial Model**:
* Purchase gold for individual users (locked to their User PDA).
* Purchase gold for the Distribution Partner's reserves.
* Withdraw unused USDC and unlocked gold from the central vault (Partner's Withdrawal Authority).
* **Self-Custody Model**:
* Facilitate user-initiated gold purchases: Provide transaction details for user signing (USDC from user wallet, gold to user wallet), manage KYC checks.
* Purchase gold for the Distribution Partner's reserves.
* Facilitate withdrawal of unused USDC and unlocked gold from their own reserves.
* The API will clearly distinguish endpoints or parameters for Custodial versus Self-Custody operations where necessary.
### Reporting & Balances API
* Current gold prices and estimates.
* **Custodial Model**: Distribution Partner's central vault contents, User PDA gold balances.
* **Self-Custody Model**: Distribution Partner's operational vault contents. User gold balances are on their respective wallets, but the API can report on KYC-verified users and their transaction history through the platform.
# Features
Source: https://docs.grail.oro.finance/grail-overview/features
### 1. Distribution Partner Models & Central Infrastructure
* Participation is restricted to **Distribution Partners**, vetted and approved by Oro.
* Each Distribution Partner is configured with a `kind`: `Custodial` or `SelfCustody`.
* **Custodial Model**: Each Distribution Partner receives a **central vault**, implemented as a PDA under the Inti program's control. Distribution Partners deposit **USDC** into this vault, which is managed through designated authorities while remaining secure under program oversight.
* **Self-Custody Model**: A central PDA (associated with the Distribution Partner or a dedicated KYC entity) acts as a **KYC approver** for the Issuance Program and associated authorities. User funds (USDC and Oro tokens) are held directly in the users' Web3 wallets. The Distribution Partner may still maintain a vault for their own operational funds and unlocked Oro tokens.
### 2. User Creation and Management
* **Custodial Model**: Distribution Partners create user accounts by submitting a **KYC-hashed identifier**, which generates a **compressed account** (User PDA) for each user. These accounts store gold balances with extreme efficiency, reducing on-chain storage costs by approximately **1000x**.
* **Self-Custody Model**: Distribution Partners facilitate user onboarding by submitting a **KYC-hashed identifier** and the **user's public wallet address**. This creates a **compressed account** (User PDA) that links the KYC hash to the user's wallet. Gold balances are held and managed directly in the user's wallet, not in the compressed account. The User PDA acts as a KYC record and authorization point.
### 3. Authority Structure
* **Executive Authority**:
* **Custodial Model**: Controlled by the Distribution Partner for day-to-day operations, such as depositing funds into the central vault, creating users, and purchasing gold for users or the partner.
* **Self-Custody Model**: Controlled by the Distribution Partner for operations like registering users (linking KYC to wallets) and purchasing gold for the Partner's own reserves. User-specific transactions (buy/sell) are initiated via the Partner's platform but signed by the **User's Wallet**.
* **Withdrawal Authority**:
* **Custodial Model**: A distinct authority (optionally multi-signature) responsible for withdrawing **unused** USDC and **unlocked** Oro tokens from the central vault.
* **Self-Custody Model**: Applies to the Distribution Partner's operational vault for withdrawing their **unused** USDC and **unlocked** Oro tokens. Users withdraw funds directly from their own wallets.
* **Update Authority**: Wallet that makes administrative and configuration changes along with API Key management.
### 4. Gold Purchase & Holding Mechanism
* **Custodial Model**:
* Distribution Partners can purchase gold:
* For **users**: Gold is **locked** and allocated to the user's compressed account within the central vault system, safeguarding their ownership.
* For the **Distribution Partner**: Gold remains **unlocked** in their portion of the central vault, allowing the Distribution Partner to manage or withdraw it as needed.
* USDC is deducted from the central vault, and gold tokens (Oro tokens) are minted to reflect the purchase.
* **Self-Custody Model**:
* **Users** purchase gold using funds from their own Web3 wallets, facilitated by the Distribution Partner's platform. Transactions are signed by the **User's Wallet**. Gold tokens (Oro tokens) are sent directly to the user's wallet.
* The **Distribution Partner** can purchase gold for their own reserves, which is held in their operational wallet or vault. USDC is deducted from their operational funds.
### 5. Gold Purchase & Holding Mechanism
* **Custodial Model**: The **Withdrawal Authority** can withdraw **unused** USDC and **unlocked** Oro tokens from the central vault at any time, ensuring liquidity while protecting locked user funds.
* **Self-Custody Model**:
* **Users** can withdraw/transfer their USDC and Gold tokens directly from their own Web3 wallets at any time.
* The Distribution Partner's **Withdrawal Authority** can withdraw **unused** USDC and **unlocked** Gold tokens from their own operational vault.
# Roadmap
Source: https://docs.grail.oro.finance/grail-overview/future-expansion
GRAIL is live with core gold infrastructure. Here's what's coming next.
## Asset Expansion
### Precious Metals
* **Silver** — Same infrastructure, different metal
* **Platinum & Palladium** — Industrial precious metals for diversified offerings
## Yield Products
### stGOLD
Yield-bearing wrapped gold. Users deposit gold tokens and earn yield from lending markets and DeFi integrations. Available for both custodial (via partner) and self-custody (direct) users.
### Gold-Backed Credit
* **Lending APIs:** Let users borrow against their gold holdings
* **Credit lines:** Revolving credit secured by gold collateral
* **Partner margin facilities:** Use your gold reserves as collateral for operational liquidity
* **Overdraft for Partners:** Use your gold portfolio as short-term collateral for purchases. Settle invoices post-purchase without pre-funding liquidity
## Rewards & Loyalty Infrastructure
### Gold Cashback
APIs for settling cashback and rewards directly in gold instead of points or cash.
### Programmable Incentives
Create rules-based reward systems: spend thresholds, referral bonuses, milestone rewards—all settled in gold.
### Partner Reward Pools
Fund reward pools in gold, distribute to users based on your program logic.
## Physical Settlement
### Redemption APIs
Enable users to redeem tokenized gold for physical delivery:
* **Delivery options** — Shipped to user's address
* **Vault pickup** — Collect from secure vault locations
* **Allocated storage** — Maintain physical allocation in audited vaults
### Denominations
Support for standard gold denominations (1oz, 10oz, 1kg bars) with delivery thresholds and fees.
## Multi-Chain Expansion
### EVM Compatibility
* **Bridge to Ethereum** — Gold tokens portable to Ethereum mainnet
* **L2 deployments** — Base, Arbitrum, Optimism support
* **Chain-abstracted APIs** — Same API, choose your settlement layer
### Cross-Chain Transfers
Move gold between chains while maintaining KYC linkage and compliance status.
# Overview
Source: https://docs.grail.oro.finance/grail-overview/overview
GRAIL is gold infrastructure designed for distribution partners — exchanges, neobanks, fintechs, and Web3 platforms — who want to offer on-chain gold ownership without building the stack themselves.
## How It Works
1. **You integrate** via REST APIs
2. **Your users** buy, hold, and sell gold through your platform
3. **GRAIL handles** custody, settlement, compliance checks, and on-chain operations
The program is open-source. Audit the code for security and compliance before going live.
## Two Models, One API
### Custodial Model
For partners who want to manage user assets on their behalf. Ideal when your users don't interact directly with blockchain.
* You deposit USDC into a central vault (a Program Derived Address controlled by the GRAIL program)
* Users get compressed accounts that track their gold balances
* All purchases and withdrawals flow through your platform
* Users never need a Web3 wallet
**Use cases:** Banking apps, investment platforms, gold savings products
### Self-Custody Model
For partners whose users already have Web3 wallets and want direct control of their assets.
* Users hold gold tokens directly in their wallets
* You handle KYC and link user identities to wallet addresses
* Transactions are signed by users, facilitated by your platform
* Gold tokens are portable and DeFi-compatible
**Use cases:** Crypto exchanges, DeFi platforms, Web3 consumer apps
## Access Model
GRAIL is whitelist-only. Only KYC-verified distribution partners approved by Oro can integrate. This ensures:
* Regulatory compliance across the network
* Quality control for end-user experience
* Proper KYC/AML procedures at the partner level
Your partner type (Custodial or Self-Custody) is defined at setup and determines your operational model.
## Technical Foundation
**Compressed Accounts** User data and balances are stored in compressed accounts, reducing on-chain storage costs by approximately 1000x compared to standard accounts.
**Program Derived Addresses (PDAs)** Central vaults and user accounts are PDAs controlled by the GRAIL program, ensuring assets are secure and operations are transparent.
**Authority Separation** Different keys control different operations (executive, withdrawal, update), reducing single-point-of-failure risk.
# Security and Compliance
Source: https://docs.grail.oro.finance/grail-overview/security-and-compliance
### Whitelisting & KYC
* Only approved Distribution Partners can participate.
* Distribution Partners retain user KYC information off-chain.
* The program enforces KYC linkage for relevant on-chain actions in both models (e.g., User PDA creation, minting/transfers in Self-Custody model involving the program).
### Audit & Transparency
* All gold purchases and transfers facilitated by the program are fully transparent on-chain.
* Distribution Partners can provide detailed audit trails to users.
### Asset Security
* **Custodial Model**: Locked gold tokens are protected by the program within the central vault system. Distribution Partners cannot withdraw user-allocated gold.
* **Self-Custody Model**: Users maintain control and responsibility for their assets in their own Web3 wallets. The program ensures that only KYC-verified users (via their linked User PDA) can interact with specific program features like initial validated mints or participation in ecosystem offerings. The security of the user's private keys is paramount.
### Multi-Signature Support
* Optional multi-signature requirements for the Partner's Withdrawal Authority (both models, for their respective vaults).
* Additional security for high-value transactions under partner control.
# Workflow Breakdown
Source: https://docs.grail.oro.finance/grail-overview/workflow-breakdown
### 1. Distribution Partner Setup & Central Vault Creation
* **Oro** whitelists a new Distribution Partner, specifying its `kind` (`Custodial` or `SelfCustody`), and initializes its central infrastructure.
* For **Custodial** partners: A **central vault PDA** is created for USDC deposits and holding locked user gold.
* For **Self-Custody** partners: A PDA is initialized to manage KYC approvals and partner-specific configurations. The partner will also have their own operational wallet/vault for their reserves, separate from user funds.
* The transaction is facilitated through the **Oro Rest API**, which provides the transaction for OroAdmin to sign and then handles the submission to the blockchain.
### 2. User Creation
* The **Distribution Partner** requests user creation via the **Oro Rest API**.
* For **Custodial Model**: The request includes a KYC-hashed identifier. The API creates the transaction to generate a compressed account (User PDA) that will store the user's gold balances. The partner does not need to provide a wallet address for the user.
* For **Self-Custody Model**: The request includes a KYC-hashed identifier and the user's public wallet address. The API creates the transaction to generate a compressed account (User PDA) that links the KYC hash to the user's wallet.
* The API provides the transaction to the Distribution Partner.
* The Distribution Partner signs the transaction with their Executive Authority Wallet and returns it to the API (along with the user's wallet signature in the Self-Custody model, if applicable).
* The API submits the signed transaction(s) to the blockchain.
* **Custodial Model**: The compressed account (User PDA) is created to hold balances.
* **Self-Custody Model**: The compressed account (User PDA) is created, linking the KYC hash to the user's specified wallet address.
### 3. Purchase Gold for Users
* The **Distribution Partner** (for Custodial users) or the **User** (via the Distribution Partner's platform for Self-Custody users) requests gold purchase via the **Oro Rest API**.
* The API creates the transaction, tailored for the specific model:
* **Custodial Model**: The Distribution Partner signs the transaction with their Executive Authority Wallet. USDC is deducted from the central vault, and gold is locked to the User PDA.
* **Self-Custody Model**: The User signs the transaction with their Web3 Wallet. USDC is deducted from the user's wallet, and gold is sent directly to the user's wallet. The Distribution Partner's platform facilitates this, and their signature might be required for orchestrating the transaction.
* The API handles the submission to the blockchain.
### 4. Gold Purchase Flow
1. **Request**:
* **Custodial**: The Distribution Partner submits a purchase request (specifying user, USDC amount) to the **Oro Rest API**.
* **Self-Custody**: The User, through the Distribution Partner's platform, submits a purchase request (USDC amount). The request includes necessary information for the API to identify the user's KYC-linked wallet.
2. **Transaction Preparation & Signing**:
* The API creates the transaction and returns it (or the necessary data for signing) to the relevant signer(s).
* **Custodial**: The Distribution Partner signs the transaction with their Executive Authority Wallet and returns it to the API.
* **Self-Custody**: The User signs the transaction with their Web3 Wallet (signature is relayed via the Partner's platform to the API). The Distribution Partner might also co-sign or provide an approval signature if required by the program logic.
3. **Submission**: The API submits the signed transaction(s) to the blockchain.
4. **Execution**: Once confirmed on-chain:
* **Custodial**: The program deducts USDC from the central vault and mints gold tokens, locking them to the specified User PDA.
* **Self-Custody**: The program facilitates the swap/purchase, deducting USDC from the user's wallet and transferring/minting gold tokens directly to the user's wallet. The Oro program ensures KYC compliance before tokens are delivered.
5. **Confirmation**: The API confirms the purchase status.
### 5. Withdrawal Flow (Partner Operations)
This flow describes how Distribution Partners can withdraw their operational funds. For end-users in a Self-Custody model, transfers of their assets are done directly from their own Web3 wallets using standard blockchain procedures, and are not part of this API-driven withdrawal flow.
1. **Request Initiation (Partner Withdrawal)**:
* **Custodial Model (Partner Withdrawal from Central Vault)**: The Distribution Partner requests a withdrawal of their unused USDC or unlocked Gold tokens (not allocated to users) via the **Oro Rest API**.
* **Self-Custody Model (Partner Withdrawal from Operational Vault)**: The Distribution Partner requests a withdrawal of their own unused USDC or unlocked Gold tokens from their operational vault/wallet via the **Rest API**.
2. **Transaction Preparation & Signing (Partner Withdrawal)**:
* The API creates the withdrawal transaction for the partner.
* The Distribution Partner signs it with their Withdrawal Authority Wallet (can be multi-sig) and returns it to the API.
3. **Submission (Partner Withdrawal)**: The API submits the partner-signed transaction to the blockchain.
4. **Execution (Partner Withdrawal)**:
* **Custodial Model**: The program transfers **unused** USDC and **unlocked** (partner-owned) gold tokens from the central vault PDA to the partner's specified wallet. **Locked** gold tokens assigned to user PDAs **cannot** be withdrawn by the partner.
* **Self-Custody Model**: The program transfers **unused** USDC and **unlocked** gold tokens from the partner's operational vault/wallet to their specified external wallet.
5. **Confirmation (Partner Withdrawal)**: The API confirms the transaction status to the partner.
# Authentication & Setup
Source: https://docs.grail.oro.finance/guides/authentication-and-setup
Mint a PARTNER API key via challenge-response and verify it against a protected endpoint.
## Overview
Every protected endpoint in GRAIL takes a single header: `x-api-key`. There is one partner scope (`PARTNER`) — one key grants access to everything a partner can do (trades, redemptions, users).
Keys are minted via **challenge-response**: you sign a short-lived message with your partner wallet's private key, and exchange the signature for a key. GRAIL stores only a hash — raw keys are shown once.
The two endpoints in the auth flow are the only `/v1/*` routes that do not themselves require authentication. They are rate-limited to **10 requests per minute per IP**.
## Prerequisites
Before you start, you need:
* Your **partner ID** (UUID) — given to you by ORO at onboarding
* Your **partner wallet keypair** — the private key that signs the challenge. This must correspond to a wallet registered under your partner (the wallet is the one ORO whitelists on-chain).
* A way to sign a UTF-8 message with Ed25519 and produce a **base64** signature. The snippet below uses `tweetnacl`.
The signature format is **base64**, not base58. Submitting base58 produces `400 invalid_signature`. This is the most common onboarding mistake.
## The flow at a glance
```
1. POST /v1/auth/challenge { wallet_address, partner_id }
↓
2. GRAIL returns { challenge_id, message, expires_at }
↓
3. You sign `message` with the partner wallet's private key → base64 signature
↓
4. POST /v1/auth/api-key { challenge_id, signature, key_name }
↓
5. GRAIL returns the raw api_key — SHOWN ONCE. Store it.
```
Challenges expire after **2 minutes**. Complete signing and exchange before then.
## Step 1 — Request a challenge
```bash theme={null}
BASE=https://grail-stack-dev.onrender.com
PARTNER_WALLET=Fd31QxW7RRZwvMfNnhNaPvczJpMh7wyzBTWvtMA66wjN
PARTNER_ID=d8a8df53-36ca-4f4d-96ef-cf0a49d51e5d
curl -s -X POST "$BASE/v1/auth/challenge" \
-H "Content-Type: application/json" \
-d "{\"wallet_address\":\"$PARTNER_WALLET\",\"partner_id\":\"$PARTNER_ID\"}"
```
Response:
```json theme={null}
{
"challenge_id": "ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"message": "Sign this message to generate an API key for GRAIL: ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"expires_at": "2026-04-19T12:32:00.000Z"
}
```
Save `challenge_id` and the full `message` string.
## Step 2 — Sign the challenge message
Sign the `message` (UTF-8 bytes) with the partner wallet's Ed25519 private key. Encode the signature as **base64**.
```typescript theme={null}
import { Keypair } from '@solana/web3.js';
import nacl from 'tweetnacl';
import bs58 from 'bs58';
// Load your partner keypair — secret in base58 is the common CLI export format
const partner = Keypair.fromSecretKey(bs58.decode(PARTNER_SECRET_BASE58));
const message = "Sign this message to generate an API key for GRAIL: ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a";
const messageBytes = new TextEncoder().encode(message);
const sigBytes = nacl.sign.detached(messageBytes, partner.secretKey);
const signatureBase64 = Buffer.from(sigBytes).toString("base64");
console.log(signatureBase64);
```
You can use any Ed25519 signing library in any language — `tweetnacl` is just the common JS choice. The only hard requirement is that the final output is base64 of the 64-byte signature.
## Step 3 — Exchange the signature for an API key
```bash theme={null}
curl -s -X POST "$BASE/v1/auth/api-key" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"signature": "",
"key_name": "integration test"
}'
```
Response:
```json theme={null}
{
"api_key": "grail_partner_7f3a...<64 hex chars>",
"key_id": "0c9bd7e4-6b2e-4f3a-a9a7-1f6e5d4c3b2a",
"scope": "PARTNER",
"key_name": "integration test",
"wallet_address": "Fd31QxW7RRZwvMfNnhNaPvczJpMh7wyzBTWvtMA66wjN",
"created_at": "2026-04-19T12:30:45.123Z"
}
```
`api_key` is shown **only once**. Store it somewhere you can retrieve it later (secret manager, encrypted env var). If lost, revoke it via [Revoke API Key](/api-reference/partner/revoke-api-key) and mint a new one.
## Step 4 — Verify the key works
Hit a protected endpoint. The denominations list is a safe one — read-only, no side effects:
```bash theme={null}
curl -s "$BASE/v1/denominations?country=PK" \
-H "x-api-key: $API_KEY"
```
If you get a JSON body with a `denominations` array, you're authenticated. If you get `401 unauthorized`, check that the header name is `x-api-key` (lowercase, with hyphen) and that you pasted the full key including the `grail_partner_` prefix.
## Managing keys
### List your keys
```bash theme={null}
curl -s "$BASE/v1/partner/api-keys" -H "x-api-key: $API_KEY"
```
Returns metadata for every key minted against your partner's wallet (including revoked ones). Raw keys are never returned.
### Revoke a key
```bash theme={null}
curl -s -X POST "$BASE/v1/partner/api-keys/$KEY_ID/revoke" \
-H "x-api-key: $API_KEY"
```
Revocation is immediate and permanent. Use this when a key is compromised or no longer needed.
It's common to mint a separate key per environment or per deployed service (e.g., `prod-backend`, `staging-cron`). Label them clearly via `key_name` — the list endpoint shows the label, which makes tracking easier.
## Troubleshooting
| Symptom | Likely cause |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `400 invalid_signature` | Signature sent as base58 instead of base64 |
| `400 invalid_challenge` | You're re-using a `challenge_id` that was already exchanged, or the challenge row doesn't exist |
| `400 challenge_expired` | Took longer than 2 minutes between Step 1 and Step 3. Request a new challenge. |
| `400 wallet_revoked` | The partner wallet's status has been flipped to `revoked` by ORO. Contact support. |
| `404 wallet_not_found` on Step 1 | The wallet isn't registered under the supplied `partner_id`. Check you're using the correct pubkey and partner ID. |
| `429 rate_limited` | More than 10 requests to `/v1/auth/*` from this IP in the last minute. Wait, then retry. |
| `401 unauthorized` on a protected endpoint after minting a key | Key may have been revoked, or the header name is wrong (must be `x-api-key`, lowercase with hyphen) |
## Next steps
With your API key working, proceed to [Creating & Managing Users](/guides/creating-and-managing-users).
# Buying Gold
Source: https://docs.grail.oro.finance/guides/buying-gold
Quote a buy, co-sign the returned transaction with partner + user wallets, submit, and wait for on-chain confirmation.
## Overview
A buy converts the user's USDC into `$GOLD` on Solana. The flow is:
1. **Quote** — `POST /v1/buy` → GRAIL returns a partially-signed transaction with a quote
2. **Co-sign** — add the partner wallet signature and the user wallet signature
3. **Submit** — `POST /v1/buy/:trade_id/submit` (or broadcast directly to any Solana RPC)
4. **Wait** — the indexer writes the `Trade` row after on-chain confirmation (\~10–15s on devnet)
5. **Fetch** — `GET /v1/trades/:trade_id` once the indexer has caught up
The quote endpoint is **stateless** — nothing is written to GRAIL's database at quote time. The authoritative `Trade` record appears only after the transaction confirms on-chain.
## Signers — the most important thing to get right
A buy transaction requires **three** signatures:
| Signer | What they sign for | Who produces the signature |
| -------------- | --------------------------------------------------------------- | ------------------------------------------- |
| GRAIL (server) | A memo instruction carrying the `trade_id` for the indexer | Already applied before GRAIL returns the tx |
| Partner wallet | `whitelisted_wallet` authorization in the inti swap instruction | You add this on the partner's behalf |
| User wallet | USDC token authority (the account that's sending USDC) | You add this on the user's behalf |
If any of the three is missing, `/submit` returns `400 broadcast_failed` with `Missing signature for public key `.
## The partially-signed transaction
The quote response contains `partially_signed_transaction` — a base64 string. It's a Solana `Transaction` that already has GRAIL's signature attached. You:
1. Deserialize it
2. `partialSign(partnerKeypair)` and `partialSign(userKeypair)`
3. Serialize it back to base64
The transaction bakes in a Solana `recentBlockhash` that expires in **\~60 seconds**. If you hold it too long, `/submit` returns `broadcast_failed: Blockhash not found`. Re-quote and try again.
## Step 1 — Get a quote
```bash theme={null}
BASE=https://grail-stack-dev.onrender.com
curl -s -X POST "$BASE/v1/buy" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"usdc_amount": 100,
"slippage_bps": 50
}' \
> /tmp/quote.json
cat /tmp/quote.json
```
Response:
```json theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"side": "buy",
"quote": {
"usdc_amount": 100,
"gold_amount": 0.0205,
"price_per_troy_oz": 4872.84,
"fee_bps": 50,
"fee_usd": 0.50,
"min_gold_out": 0.0204
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
### Slippage
Two ways to set the slippage floor:
* **`slippage_bps`** (default `50` = 0.5%) — GRAIL computes `min_gold_out = quoted_gold * (10000 - slippage_bps) / 10000`
* **`min_gold_out`** — absolute override. If supplied, `slippage_bps` is ignored.
On devnet the Pyth gold price updates infrequently (every few minutes). With tight slippage on a stale quote, trades can revert with `SlippageExceeded` on-chain. If you see this, widen to 100–300 bps.
## Step 2 — Co-sign with partner + user
```typescript theme={null}
import { Transaction, Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const PARTNER_SK = bs58.decode(PARTNER_SECRET_BASE58);
const USER_SK = bs58.decode(USER_SECRET_BASE58);
// Deserialize the partial-signed tx
const pstxB64 = quote.partially_signed_transaction;
const tx = Transaction.from(Buffer.from(pstxB64, "base64"));
// Co-sign with partner, then user
tx.partialSign(Keypair.fromSecretKey(PARTNER_SK));
tx.partialSign(Keypair.fromSecretKey(USER_SK));
// Serialize the fully-signed tx back to base64
const signedB64 = tx.serialize().toString("base64");
```
In a real deployment, the user signs on the client side and the partner's server appends the partner signature before broadcast. You don't have to co-sign in a single process — only the final serialized transaction matters.
## Step 3 — Submit
Two options — both reach the same indexer.
### Option A: Submit via GRAIL
```bash theme={null}
curl -s -X POST "$BASE/v1/buy/$TRADE_ID/submit" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"signed_tx\":\"$SIGNED_B64\"}"
```
Response:
```json theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58"
}
```
GRAIL runs Solana's pre-flight simulation. If simulation fails (wrong signer, slippage blown up by a stale price, insufficient balance, etc.), you get `400 broadcast_failed` with the reason in `message`.
### Option B: Broadcast directly
```typescript theme={null}
import { Connection } from "@solana/web3.js";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const txHash = await connection.sendRawTransaction(Buffer.from(signedB64, "base64"));
```
The indexer picks up any confirmed transaction carrying the `trade_id` memo — it doesn't matter who broadcasts.
If you want a transaction to actually land and revert on-chain (for testing the failure path), broadcast directly with `skipPreflight: true`. GRAIL's `/submit` pre-flight would otherwise reject the tx before it lands.
## Step 4 — Wait and fetch
The indexer writes the `Trade` row after on-chain confirmation, typically 10–15 seconds on devnet. Polling `GET /v1/trades/:trade_id` right after submit returns `404 trade_not_found` until the indexer catches up.
```bash theme={null}
sleep 15
curl -s "$BASE/v1/trades/$TRADE_ID" -H "x-api-key: $API_KEY"
```
Response (successful):
```json theme={null}
{
"trade_id": "trd_4e7a1b8f-9c32-4a91-b3e6-7f12a8d4c5e9",
"side": "buy",
"status": "confirmed",
"usdc_amount": 100,
"gold_amount": 0.020528,
"price_per_troy_oz": 4872.84,
"fee_bps": 50,
"fee_usd": 0.50,
"submitted_tx_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp...base58",
"created_at": "2026-04-17T10:18:30.000Z",
"updated_at": "2026-04-17T10:18:30.000Z"
}
```
A `status: "failed"` row is written if the transaction landed on-chain but the inti program reverted (e.g., `SlippageExceeded`). On failures, `usdc_amount` reflects the input from the memo; `gold_amount` and the price/fee fields are `0` (the program never computed them).
## End-to-end reference script
```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail
BASE=https://grail-stack-dev.onrender.com
USER_ID=gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3
# 1. Quote
curl -s -X POST "$BASE/v1/buy" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d "{\"grail_user_id\":\"$USER_ID\",\"usdc_amount\":10,\"slippage_bps\":50}" \
> /tmp/quote.json
TRADE_ID=$(jq -r .trade_id /tmp/quote.json)
PSTX=$(jq -r .partially_signed_transaction /tmp/quote.json)
# 2. Co-sign
SIGNED=$(npx tsx -e "
import { Transaction, Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
const partner = Keypair.fromSecretKey(bs58.decode('$PARTNER_SK'));
const user = Keypair.fromSecretKey(bs58.decode('$USER_SK'));
const tx = Transaction.from(Buffer.from(process.argv[1], 'base64'));
tx.partialSign(partner);
tx.partialSign(user);
process.stdout.write(tx.serialize().toString('base64'));
" "$PSTX")
# 3. Submit
curl -s -X POST "$BASE/v1/buy/$TRADE_ID/submit" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d "{\"signed_tx\":\"$SIGNED\"}"
# 4. Wait + fetch
sleep 15
curl -s "$BASE/v1/trades/$TRADE_ID" -H "x-api-key: $API_KEY"
```
## Common errors
| Error | Likely cause |
| ------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `400 kyc_level_insufficient` | User's KYC is not `full` |
| `400 onchain_config_missing` | ORO hasn't set up your partner's on-chain config yet. Contact ORO. |
| `400 broadcast_failed: Blockhash not found` | Co-signed too slowly — quote's blockhash expired. Re-quote. |
| `400 broadcast_failed: Missing signature for public key ...` | Didn't co-sign with all three required signers |
| `400 broadcast_failed: SlippageExceeded` | Price moved between quote and submit beyond `min_gold_out`. Widen slippage. |
| `404 trade_not_found` (right after submit) | Indexer hasn't written the row yet. Wait 15s and retry. |
| `503 pricing_unavailable` | Pyth oracle unreachable or returned data older than the staleness window |
## Next steps
Close the loop with [Selling Gold](/guides/selling-gold), or jump to [Redeeming Physical Gold](/guides/redeeming-physical-gold) for physical fulfillment.
# Creating & Managing Users
Source: https://docs.grail.oro.finance/guides/creating-and-managing-users
Register end-users under your partner with flexible-JSON KYC. Users must reach kyc_level: full before they can trade or redeem.
## Overview
Every end-user that trades or redeems through your platform is registered in GRAIL as a user under your partner. GRAIL stores:
* Your internal identifier for the user (`partner_user_id`)
* The user's Solana wallet address (unique across all GRAIL users)
* A KYC record — a mix of required structured fields and free-form JSON
Only users with `kyc_level: "full"` can quote trades or redemptions. Partial or basic KYC is rejected at user creation.
GRAIL does not run KYC for you. You KYC users on your side, then push the verified record to GRAIL via the create endpoint. GRAIL stores the record and uses it to gate trading.
## The KYC model
Core fields (structured columns, all required):
| Field | Type | Notes |
| ----------------- | -------- | --------------------------------------------------------------------- |
| `country` | string | ISO-3166-1 alpha-2 (`PK`, `AE`, `SA`, …). Must be active in GRAIL. |
| `full_name` | string | 1–255 characters. |
| `kyc_provider` | string | Label of your KYC provider (e.g., `"sumsub"`, `"manual"`). Free-form. |
| `kyc_level` | string | Must be `"full"`. |
| `kyc_verified_at` | ISO-8601 | When you completed KYC on your side. |
Free-form (optional, JSON object):
* `kyc_data` — any provider-specific fields you want to retain (e.g., document types, ID numbers, risk scores). Stored verbatim.
### Advisory ID validation
If `kyc_data` includes both `id_type` and `id_number`, GRAIL looks up the country/ID-type pair in its registry and — if a regex is registered — validates `id_number` against it. For example, for Pakistan:
* `id_type: "CNIC"` → `id_number` must match `\d{5}-\d{7}-\d` (e.g., `12345-1234567-1`)
This is **advisory only**: if the regex isn't registered for that country/ID-type, no validation happens. GRAIL does not verify ID-number authenticity — only format.
## Step 1 — Look up supported countries
Before registering users, make sure the user's country is supported. The list is stable; you only need to do this once. If a country you need isn't supported, contact ORO.
There is no public endpoint to list countries in the partner API today. ORO seeds the country list (e.g., `PK`, `AE`, `SA` on devnet). If you submit a user with an unsupported country, you'll get `400 invalid_request: Unsupported or inactive country`.
## Step 2 — Create the user
```bash theme={null}
BASE=https://grail-stack-dev.onrender.com
curl -s -X POST "$BASE/v1/users" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"kyc": {
"country": "PK",
"full_name": "Alice Example",
"kyc_provider": "manual",
"kyc_level": "full",
"kyc_verified_at": "2026-04-17T00:00:00Z",
"kyc_data": {
"id_type": "CNIC",
"id_number": "12345-1234567-1"
}
}
}'
```
Response:
```json theme={null}
{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"created_at": "2026-04-17T10:15:30.000Z"
}
```
Save the `grail_user_id` (prefixed `gu_`). You need it for every subsequent trade and redemption call. Your own `user_id` is retained (as `partner_user_id`) but GRAIL's API always keys off `grail_user_id`.
### Uniqueness rules
GRAIL enforces two uniqueness constraints:
1. `wallet_address` is unique **globally** across all GRAIL users (any partner). You cannot register the same Solana wallet twice.
2. `user_id` is unique **within your partner**. Two partners may each have a user with `user_id: "user_001"`, but you cannot have two.
Violations return `409 user_already_exists`.
## Step 3 — Look up a user
```bash theme={null}
curl -s "$BASE/v1/users/gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3" \
-H "x-api-key: $API_KEY"
```
Response:
```json theme={null}
{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"user_id": "partner_internal_user_001",
"wallet_address": "2u7vVGJCTtsijCqWJNCEknVkuLjeU7Rd4PMg4dXuTZGx",
"status": "active",
"created_at": "2026-04-17T10:15:30.000Z",
"kyc": {
"country": "PK",
"full_name": "Alice Example",
"kyc_level": "full",
"kyc_provider": "manual",
"kyc_verified_at": "2026-04-17T00:00:00.000Z",
"kyc_data": {
"id_type": "CNIC",
"id_number": "12345-1234567-1"
}
}
}
```
Users belonging to a different partner return `403 partner_mismatch` — each partner can only see its own users.
## Trade / redemption prerequisites
The following gate every buy, sell, and redemption call. If a user fails any of them, the quote is rejected before the transaction is built:
1. User must exist (`404 user_not_found` if not)
2. User must belong to the authenticated partner (`403 partner_mismatch`)
3. User `status` must be `"active"` (`403 user_suspended`)
4. User `kyc_level` must be `"full"` (`400 kyc_level_insufficient`)
5. Your partner must have an on-chain config set up (`400 onchain_config_missing`) — this is set up by ORO at onboarding
6. Your partner must have a registered wallet (`400 wallet_missing`) — also set up by ORO
In practice, (5) and (6) are true from day one if you were onboarded correctly. Most user-facing failures are either (3) or (4).
## Suspending users
User suspension is not a partner-facing API today — suspensions are performed by ORO's admin interface. If you need a user blocked, contact ORO.
## Next steps
With a KYC-full user registered, proceed to [Buying Gold](/guides/buying-gold) to execute your first trade.
# Integration Guides
Source: https://docs.grail.oro.finance/guides/overview
Step-by-step guides for integrating with the Oro GRAIL API.
# Integration Guides
These guides walk through every end-to-end flow GRAIL exposes, from minting your first API key to quoting a trade, co-signing the returned Solana transaction, and tracking confirmation. Each builds on the previous and includes copy-pasteable code.
## Before you begin
You'll need, from ORO:
* Your **partner ID** (UUID)
* Your **partner wallet** (Solana keypair) — whitelisted on-chain by ORO
* Confirmation that your **IntegratorV2 PDA** is initialized on-chain (ORO does this at onboarding)
* At least one **end-user wallet** registered in GRAIL, or the ability to register one yourself via [Create User](/api-reference/users/create-user)
And locally:
* **Node.js 18+** (Node 20 or 22 recommended)
* `@solana/web3.js`, `tweetnacl`, `bs58` installed in your integration project (used for message signing + transaction co-signing)
* An HTTP client — curl, Postman, etc.
## Base URL
```
https://grail-stack-dev.onrender.com
```
All examples in the guides assume this as `$BASE`.
## Guides
Mint a PARTNER API key via the challenge-response flow.
Register end-users with flexible-JSON KYC under your partner.
Quote, co-sign (GRAIL + partner + user), submit, and track on-chain confirmation.
Mirror of the buy flow — quote, three-signer tx, submit, confirm.
Convert `$GOLD` tokens to a physical-gold denomination for pickup.
## Recommended order
1. **[Authentication & Setup](/guides/authentication-and-setup)** — mint your partner API key
2. **[Creating & Managing Users](/guides/creating-and-managing-users)** — register your first end-user
3. **[Buying Gold](/guides/buying-gold)** — execute your first buy
4. **[Selling Gold](/guides/selling-gold)** — close the loop with a sell
5. **[Redeeming Physical Gold](/guides/redeeming-physical-gold)** — optional, for integrations that include physical fulfillment
# Redeeming Physical Gold
Source: https://docs.grail.oro.finance/guides/redeeming-physical-gold
Convert a user's $GOLD tokens into a physical-gold denomination for pickup. Two-signer transaction (GRAIL + user), and a multi-step admin lifecycle for fulfillment.
## Overview
A redemption converts a user's `$GOLD` tokens into a physical-gold denomination (e.g., `1 Tola`) that ORO fulfills off-chain in a specific city. The token flow is on-chain — the user transfers tokens into GRAIL's escrow. Fulfillment (physical pickup) is managed by ORO and progresses through a status lifecycle that the partner can observe but not drive.
Redemptions differ from trades in three important ways:
1. **Only two signers** — GRAIL + user. **The partner does not sign.**
2. **Row written at quote time** — a redemption row is created at status `quoted` as soon as you call `POST /v1/redemptions` (unlike trades, which are stateless at quote time).
3. **`quoted` is never surfaced** — `GET /v1/redemptions/:id` returns `404` until the user's transfer has been confirmed on-chain and the indexer advances status to `submitted`.
## Status lifecycle
```
quoted (internal — never visible via API)
└─► submitted ← indexer writes after on-chain confirmation
├─► preparing ← ORO admin: starting physical pickup prep
│ └─► ready ← ORO admin: gold ready at pickup location
│ └─► collected (FINAL)
│
└─► cancellation_requested ← partner (only valid at `submitted`)
└─► cancelled (FINAL)
```
* Indexer writes `quoted → submitted` (or `quoted → failed` if the on-chain transfer reverts)
* ORO admin drives `submitted → preparing → ready → collected`
* Partner can request cancel **only while status is `submitted`** — once ORO advances to `preparing`, physical prep has begun and cancellation is a coordination question, not an automated one
## Step 1 — Look up available denominations
```bash theme={null}
BASE=https://grail-stack-dev.onrender.com
curl -s "$BASE/v1/denominations?country=PK" \
-H "x-api-key: $API_KEY"
```
Response:
```json theme={null}
{
"denominations": [
{
"id": "pk_tola_1",
"label": "1 Tola",
"weight_g": 11.664,
"weight_troy_oz": 0.375,
"city": "karachi"
}
]
}
```
Pick the `id` for [Step 2](#step-2-quote-a-redemption) and note the `city` — you must pass the same city in the quote body (case-insensitive).
## Step 2 — Quote a redemption
```bash theme={null}
curl -s -X POST "$BASE/v1/redemptions" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"denomination_id": "pk_tola_1",
"city": "karachi"
}'
```
Response:
```json theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"quote": {
"denomination": "1 Tola",
"weight_g": 11.664,
"spot_price_usd": 4872.84,
"gold_value_usd": 1827.34,
"fee_usd": 0,
"total_usd": 1827.34,
"tokens_required": "0.375006"
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
`tokens_required` is the amount of **`$GOLD` tokens** (per troy ounce, 6 decimals) the user must transfer. In the example above: `0.375006` tokens for 1 Tola. This is **not** the same as `total_usd` (`1827.34`) — that's the USD value of those tokens. Mixing them up is the most common redemption mistake.
## Step 3 — Co-sign with the user wallet ONLY
Unlike trades, the partner does not sign. Only the user needs to add their signature.
```typescript theme={null}
import { Transaction, Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const tx = Transaction.from(Buffer.from(quote.partially_signed_transaction, "base64"));
tx.partialSign(Keypair.fromSecretKey(bs58.decode(USER_SECRET_BASE58)));
const signedB64 = tx.serialize().toString("base64");
```
## Step 4 — Submit
```bash theme={null}
curl -s -X POST "$BASE/v1/redemptions/$REDEMPTION_ID/submit" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"signed_tx\":\"$SIGNED_B64\"}"
```
Response:
```json theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"tx_hash": "4ABC...base58"
}
```
Same passthrough semantics as trade submit — no DB write. The indexer writes the status transition after on-chain confirmation.
## Step 5 — Wait, then fetch
```bash theme={null}
sleep 15
curl -s "$BASE/v1/redemptions/$REDEMPTION_ID" -H "x-api-key: $API_KEY"
```
Once the indexer has advanced the row:
```json theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "submitted",
"denomination": "1 Tola",
"weight_g": 11.664,
"city": "karachi",
"quote": {
"spot_price_usd": 4872.84,
"gold_value_usd": 1827.34,
"fee_usd": 0,
"total_usd": 1827.34,
"tokens_required": "0.375006"
},
"submitted_tx_hash": "4ABC...base58",
"cancellation_reason": null,
"created_at": "2026-04-17T11:00:00.000Z",
"updated_at": "2026-04-17T11:00:15.000Z"
}
```
While the redemption is still at `quoted`, this endpoint returns `404 redemption_not_found` — treat that as "indexer hasn't confirmed yet", not as an error.
## Tracking fulfillment
The partner cannot drive lifecycle transitions from `submitted` onward. ORO does that in the admin interface. As a partner, poll `GET /v1/redemptions/:id` (or use [List Redemptions](/api-reference/redemptions/list-redemptions) with a status filter) to see when the row advances. Typical transitions:
* `submitted` → `preparing` when ORO begins physical handling
* `preparing` → `ready` when gold is at the pickup location
* `ready` → `collected` after the user collects
## Cancelling
Only valid at `submitted`:
```bash theme={null}
curl -s -X POST "$BASE/v1/redemptions/$REDEMPTION_ID/cancel" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"user changed mind"}'
```
Response:
```json theme={null}
{
"redemption_id": "red_a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "cancellation_requested"
}
```
If the redemption has moved past `submitted`, you get `400 cancellation_not_allowed`. From `cancellation_requested`, ORO advances to `cancelled` (final) after confirming no physical prep has started.
The user's `$GOLD` has already been transferred to escrow at `submitted`. Cancellation flags the record — it does NOT automatically return the tokens to the user. Any refund is a coordination question with ORO, not an automated flow.
## Listing
```bash theme={null}
curl -s "$BASE/v1/redemptions?status=submitted" -H "x-api-key: $API_KEY"
```
Optional filters: `grail_user_id`, `status`, `city`. `quoted` is always excluded.
## Common errors
| Error | Likely cause |
| ------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `400 invalid_denomination` | `denomination_id` doesn't exist or is inactive |
| `400 invalid_city` | `city` in the request body doesn't match the denomination's registered city |
| `400 kyc_level_insufficient` | User's KYC is not `full` |
| `400 broadcast_failed: Blockhash not found` | Co-signed too slowly — blockhash expired. Re-quote. |
| `400 broadcast_failed: insufficient funds` | User's `$GOLD` balance was insufficient for `tokens_required` |
| `400 cancellation_not_allowed` | Redemption has already moved past `submitted` |
| `404 redemption_not_found` | Redemption is still at internal `quoted` (indexer hasn't confirmed yet) or doesn't exist |
## End-to-end script
```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail
BASE=https://grail-stack-dev.onrender.com
USER_ID=gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3
# 1. Quote
curl -s -X POST "$BASE/v1/redemptions" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d "{\"grail_user_id\":\"$USER_ID\",\"denomination_id\":\"pk_tola_1\",\"city\":\"karachi\"}" \
> /tmp/rq.json
RID=$(jq -r .redemption_id /tmp/rq.json)
PSTX=$(jq -r .partially_signed_transaction /tmp/rq.json)
# 2. Co-sign with USER wallet only (no partner signature on redemptions)
SIGNED=$(npx tsx -e "
import { Transaction, Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
const user = Keypair.fromSecretKey(bs58.decode('$USER_SK'));
const tx = Transaction.from(Buffer.from(process.argv[1], 'base64'));
tx.partialSign(user);
process.stdout.write(tx.serialize().toString('base64'));
" "$PSTX")
# 3. Submit
curl -s -X POST "$BASE/v1/redemptions/$RID/submit" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d "{\"signed_tx\":\"$SIGNED\"}"
# 4. Poll until status=submitted
sleep 15
curl -s "$BASE/v1/redemptions/$RID" -H "x-api-key: $API_KEY"
```
# Selling Gold
Source: https://docs.grail.oro.finance/guides/selling-gold
Mirror of the buy flow — quote a sell, co-sign with partner + user, submit, and wait for indexer confirmation.
## Overview
A sell converts the user's `$GOLD` tokens back into USDC on Solana. The flow and signer set are **identical to [Buying Gold](/guides/buying-gold)** — the only differences are the endpoint (`/v1/sell`) and the input/output fields (you specify `gold_amount` instead of `usdc_amount`; `min_usdc_out` replaces `min_gold_out`).
Because everything else is the same — three signers, blockhash TTL, stateless quote, indexer-driven row writes — this guide focuses on the differences.
## Signers
Same as buy: **GRAIL + partner + user.** Three signatures required.
## Step 1 — Quote
```bash theme={null}
BASE=https://grail-stack-dev.onrender.com
curl -s -X POST "$BASE/v1/sell" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"grail_user_id": "gu_6b60956e-a8ee-4de2-8128-04c7fdf633c3",
"gold_amount": 0.01,
"slippage_bps": 50
}'
```
Response:
```json theme={null}
{
"trade_id": "trd_8b21d7f3-2a4c-4b5e-9d1f-3e8a7c6b5d4e",
"side": "sell",
"quote": {
"gold_amount": 0.01,
"usdc_amount": 48.49,
"price_per_troy_oz": 4872.84,
"fee_bps": 75,
"fee_usd": 0.37,
"min_usdc_out": 48.25
},
"partially_signed_transaction": "AQAAAAABAAEC......"
}
```
### Sell-specific input fields
| Field | Required | Notes |
| -------------- | -------- | ------------------------------------------------------------ |
| `gold_amount` | yes | `$GOLD` tokens to sell (human decimal). |
| `slippage_bps` | no | Default `50` (0.5%). Ignored if `min_usdc_out` is set. |
| `min_usdc_out` | no | Absolute USDC floor. Overrides `slippage_bps` when provided. |
### Sell-specific fee note
The quote's `fee_bps` often differs from a buy quote — on-chain inti uses separate `market_open_fee_bps` and `market_close_fee_bps` values, with the applicable side depending on market state. A partner's IntegratorV2 on-chain config can also have a `market_fee_override` that forces one or the other. You don't need to manage this — GRAIL reads the effective rate from the partner's on-chain config when it builds the quote.
## Step 2 — Co-sign with partner + user
Identical to buy:
```typescript theme={null}
import { Transaction, Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const tx = Transaction.from(Buffer.from(quote.partially_signed_transaction, "base64"));
tx.partialSign(Keypair.fromSecretKey(bs58.decode(PARTNER_SECRET_BASE58)));
tx.partialSign(Keypair.fromSecretKey(bs58.decode(USER_SECRET_BASE58)));
const signedB64 = tx.serialize().toString("base64");
```
The only difference from a buy tx is the on-chain program's internal direction — GOLD out of the user's token account, USDC in. You don't touch that; it's baked into the partial-signed transaction.
## Step 3 — Submit
```bash theme={null}
curl -s -X POST "$BASE/v1/sell/$TRADE_ID/submit" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"signed_tx\":\"$SIGNED_B64\"}"
```
Response:
```json theme={null}
{
"trade_id": "trd_8b21d7f3-2a4c-4b5e-9d1f-3e8a7c6b5d4e",
"tx_hash": "3xJk9Pq2n...base58"
}
```
Pure passthrough — no DB write. As with buys, you can also broadcast directly to any Solana RPC.
## Step 4 — Wait and fetch
```bash theme={null}
sleep 15
curl -s "$BASE/v1/trades/$TRADE_ID" -H "x-api-key: $API_KEY"
```
The response shape is identical to [Get Trade](/api-reference/trades/get-trade), with `side: "sell"`. `usdc_amount` is the output, `gold_amount` is the input.
## End-to-end script
Same as the buy reference script in [Buying Gold](/guides/buying-gold), with two changes:
1. Quote endpoint → `/v1/sell`
2. Quote body → `{"grail_user_id":"...", "gold_amount":0.01, "slippage_bps":50}`
Everything else — co-sign, submit endpoint (`/v1/sell/$TRADE_ID/submit`), indexer polling — is identical.
## Next steps
Flow complete? Move on to [Redeeming Physical Gold](/guides/redeeming-physical-gold) for physical fulfillment.
# GRAIL
Source: https://docs.grail.oro.finance/index
Digital gold infrastructure for modern finance
# Understanding GRAIL
It lets any fintech offer gold-powered products: savings accounts, yield, credit, rewards, and physical redemption via simple APIs.
Built by Oro, GRAIL handles the hard parts: regulatory compliance, physical gold custody, on-chain settlement, and KYC orchestration. You focus on your users.
## Choose Your Model
| | Custodial | Self-Custody |
| :---------------------- | :------------------------------------------------------------------------------ | :--------------------------------------------- |
| **Who holds assets** | You manage user balances | Users hold in their own wallets |
| **Best for** | Neobanks, traditional fintechs, apps where users don't interact with blockchain | Crypto exchanges, DeFi platforms, Web3 apps |
| **User experience** | Seamless—users never see blockchain complexity | Users sign transactions with their wallets |
| **Your responsibility** | KYC, user management, balance tracking via API | KYC, platform access, transaction facilitation |
## Use Cases
**Savings & Deposits** Let users save in gold instead of cash with seamless deposits, withdrawals, and balance tracking.
**Credit & BNPL** Build gold-backed credit lines and buy-now-pay-later flows with real-time controls.
**Yield Accounts** Offer gold balances that generate real, native yield safely over time.
**Round-Ups** Automatically convert spare change into long-term gold savings.
**Rewards & Loyalty** Create programmable rewards, incentives, and cashback systems settled directly in gold.
**Group & Shared Savings** Enable shared gold savings with transparent balances, rules, and permissions.
## Why GRAIL?
**For Product Teams**
* Launch gold savings, DCA, or yield features without commodity expertise
* White-label ready — your brand, our infrastructure
* Ship in days, not months
**For Engineering**
* Modular REST APIs with clear documentation
* Compressed accounts minimize on-chain costs (\~1000x reduction)
* Open-source program code for full auditability
**For Compliance**
* Whitelist-only access for vetted partners
* KYC-gated transactions across both models
* Full on-chain audit trail