Skip to main content

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

SymptomLikely cause
400 invalid_signatureSignature sent as base58 instead of base64
400 invalid_challengeYou’re re-using a challenge_id that was already exchanged, or the challenge row doesn’t exist
400 challenge_expiredTook longer than 2 minutes between Step 1 and Step 3. Request a new challenge.
400 wallet_revokedThe partner wallet’s status has been flipped to revoked by ORO. Contact support.
404 wallet_not_found on Step 1The wallet isn’t registered under the supplied partner_id. Check you’re using the correct pubkey and partner ID.
429 rate_limitedMore 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 keyKey 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.