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:
- Only two signers — GRAIL + user. The partner does not sign.
- 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).
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
BASE=https://grail-stack-dev.onrender.com
curl -s "$BASE/v1/denominations?country=PK" \
-H "x-api-key: $API_KEY"
Response:
{
"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 and note the city — you must pass the same city in the quote body (case-insensitive).
Step 2 — Quote a redemption
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:
{
"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...<base64>..."
}
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.
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
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:
{
"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
sleep 15
curl -s "$BASE/v1/redemptions/$REDEMPTION_ID" -H "x-api-key: $API_KEY"
Once the indexer has advanced the row:
{
"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 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:
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:
{
"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
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
#!/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"