Skip to main content

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 surfacedGET /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:
  • submittedpreparing when ORO begins physical handling
  • preparingready when gold is at the pickup location
  • readycollected 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

ErrorLikely cause
400 invalid_denominationdenomination_id doesn’t exist or is inactive
400 invalid_citycity in the request body doesn’t match the denomination’s registered city
400 kyc_level_insufficientUser’s KYC is not full
400 broadcast_failed: Blockhash not foundCo-signed too slowly — blockhash expired. Re-quote.
400 broadcast_failed: insufficient fundsUser’s $GOLD balance was insufficient for tokens_required
400 cancellation_not_allowedRedemption has already moved past submitted
404 redemption_not_foundRedemption 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"