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
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:
{
"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.
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
curl -s -X POST "$BASE/v1/auth/api-key" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "ch_9b5a3a12-7b89-4d6c-9f6a-18c52d3f9e3a",
"signature": "<base64 from Step 2>",
"key_name": "integration test"
}'
Response:
{
"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 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:
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
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
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.