Skip to main content

Getting Started

The Transfer Agent API provides a unified interface for investor whitelisting and KYC data ingestion.

Feature Overview​

Investor Whitelisting​

Submit investor KYC data and whitelist their wallet address on the blockchain.

KYC Data Ingestion​

Store investor KYC data without whitelisting. Useful for pre-registration flows or when whitelisting is handled separately.

Token Minting​

Mint tokens to a wallet address. Useful for automated token distributions on demand.

Investor Purchase Sessions​

The issuer creates a purchase session. The issuer then redirects the investor to the returned url.

  • Production: https://invest.upside.gg/p/...
  • Staging: https://invest-staging.upside.gg/p/...

Upside hosts the investor steps. The issuer receives a callback, or the issuer reads the session status.

Whitelist Status Check​

Query the current whitelist status of any wallet address.

KYC Read (Transfer Agent)​

Read KYC/PII data for a wallet address. Requires Cognito JWT with transfer-agent group. Returns account data and presigned S3 URLs for identity documents (1-minute expiry).

Architecture Overview​

Sequence Diagram​

Processing Flow​

  1. Client submits investor data via POST /api/v1/investor/whitelist
  2. TA API validates request and adds to processing queue
  3. Client receives synchronous response 201 { status: "QUEUED" }

Async processing begins:

  1. TA API sends investor KYC data to PII Storage
  2. PII Storage confirms successful storage
  3. TA API requests transaction signing from Signer API
  4. Signer API returns signed whitelist transaction
  5. TA API sends signed transaction to Relayer API
  6. Relayer API submits transaction to blockchain
  7. Blockchain returns transaction hash
  8. Relayer API returns transaction result to TA API
  9. TA API delivers callback with status and txId to client

Base URLs​

EnvironmentURL
Productionhttps://ta.upside.gg
Staginghttps://ta-staging.upside.gg

Authentication​

The TA API supports two authentication methods:

MethodEndpointsDescription
API Key + SignaturePOST whitelist, kyc, mint, purchases; GET whitelist_status, purchase statusEd25519 public key in X-Api-Key; POST requests require X-Request-Sig-B64 signature
Cognito JWTGET /api/v1/investor/kyc/:wallet_addressBearer token from Cognito; user must be in transfer-agent group

API Key Authentication​

Request Headers​

HeaderRequiredDescription
X-Api-KeyYesYour API key (Ed25519 public key, base64-encoded). Required for all API key endpoints.
X-Request-Sig-B64POST onlyBase64-encoded Ed25519 signature of the canonicalized request body, verified using the public key from X-Api-Key.

Generating API Keys​

Generate an Ed25519 key pair. The public key (base64-encoded) is your API key. Share it with Upside to get access to the API.

The API key belongs to one asset. Use the key to whitelist investors, store KYC data, mint tokens, and create purchase sessions for that asset. A purchase session requires the purchase capability.

import { sign } from 'tweetnacl';

// Generate a new key pair
const keyPair = sign.keyPair();

const publicKeyBase64 = Buffer.from(keyPair.publicKey).toString('base64');
const secretKeyBase64 = Buffer.from(keyPair.secretKey).toString('base64');

console.log('API Key (Public):', publicKeyBase64);
console.log('Secret Key (keep private!):', secretKeyBase64);

Signing Requests​

POST requests require Ed25519 signature verification:

import { sign } from 'tweetnacl';
import canonicalize from 'canonicalize';

function signRequest(body: object, secretKey: Uint8Array): string {
const canonicalBody = canonicalize(body);
const messageBytes = Buffer.from(canonicalBody, 'utf-8');
const signature = sign.detached(messageBytes, secretKey);
return Buffer.from(signature).toString('base64');
}

// Usage
const requestBody = {
investor: { /* ... */ },
callback: { url: 'https://your-server.com/callback' },
timestamp: Date.now(),
};

const secretKey = Buffer.from('YOUR_SECRET_KEY_BASE64', 'base64');
const signature = signRequest(requestBody, secretKey);
// Send signature in X-Request-Sig-B64 header

Timestamp Validation​

POST requests must include a timestamp field (Unix epoch milliseconds). The timestamp must be within ±60 seconds of server time.

Cognito JWT Authentication (Transfer Agent)​

The GET /api/v1/investor/kyc/:wallet_address endpoint uses Cognito JWT authentication. Users must:

  1. Sign in via Cognito Hosted UI (or programmatically)
  2. Complete MFA (TOTP/authenticator app) — MFA is required for all users
  3. Be a member of the transfer-agent group
  4. Send the access token in the Authorization: Bearer <token> header

This endpoint is intended for the Transfer Agent Dashboard and other internal tools that need to read KYC/PII data for compliance purposes.

Rate Limiting​

API-key endpoints are rate limited per API key. Three fixed windows are enforced at the same time, and a request is rejected if it would exceed any of them:

WindowLimit
Per second30 requests
Per minute600 requests
Per hour7,200 requests
tip

Spread bulk operations (e.g. whitelisting many investors) over time to stay under the limits, and treat a 429 as a signal to retry with backoff rather than an error.

API Endpoints​

POST /api/v1/investor/whitelist​

Submit an investor for whitelisting.

Request:

{
"investor": {
"id": "investor_12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"dob": "1990-01-15",
"fullAddress": {
"street": "123 Main St",
"city": "Berlin",
"region": "BE",
"countryCode": "DE",
"postalCode": "10115"
},
"tin": "123-45-6789",
"blockchainWallet": {
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH"
},
"primaryPhone": {
"number": "+49123456789"
}
},
"callback": {
"url": "https://your-server.com/whitelist-callback"
},
"timestamp": 1734567890123,
"verifiedStatus": true,
"verifiedAt": "2024-01-15T10:30:00.000Z"
}
FieldRequiredDescription
investorYesInvestor PII data (see Investor Fields below)
callback.urlYesURL to receive status callbacks
timestampYesRequest timestamp (ms since epoch)
verifiedStatusNoKYC verification status (default: true)
verifiedAtNoKYC verification timestamp ISO 8601 (default: current time)

Investor Fields:

FieldRequiredDescription
idYesUnique investor identifier
firstNameYesLegal first name
lastNameYesLegal last name
emailYesEmail address
dobYesDate of birth (YYYY-MM-DD)
fullAddressYesPhysical address object
tinYesTax Identification Number
blockchainWalletYesWallet address object
primaryPhoneYesPhone number object
accreditationNoAccreditation status: accredited, non_accredited, or pending
ssnNoSocial Security Number (for U.S. investors)
passportNumberNoPassport number (for non-U.S. investors)
identityDocumentCountryNoISO 3166-1 alpha-2 country code of ID issuing country
identityDocumentNoIdentity document photo (file upload)
proofOfAddressNoProof of address document (if ID country differs from residence)

File Upload Format:

{
"fileName": "passport.jpg",
"contentType": "image/jpeg",
"size": 102400,
"data": "base64-encoded-file-content"
}

Response (201 Created):

{
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"status": "QUEUED",
"message": "Whitelisting request accepted and queued for processing"
}

POST /api/v1/investor/kyc​

Submit KYC data without whitelisting transaction.

Request:

{
"investor": {
"id": "investor_12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"dob": "1990-01-15",
"fullAddress": {
"street": "123 Main St",
"city": "Berlin",
"countryCode": "DE",
"postalCode": "10115"
},
"tin": "123-45-6789",
"blockchainWallet": {
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH"
},
"primaryPhone": {
"number": "+49123456789"
}
},
"callback": {
"url": "https://your-server.com/kyc-callback"
},
"timestamp": 1734567890123,
"verifiedStatus": true,
"verifiedAt": "2024-01-15T10:30:00.000Z"
}
FieldRequiredDescription
investorYesInvestor PII data
callback.urlYesURL to receive status callbacks
timestampYesRequest timestamp (ms since epoch)
verifiedStatusYesKYC verification status
verifiedAtYesKYC verification timestamp (ISO 8601)

Response (201 Created):

{
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"status": "QUEUED",
"message": "KYC request accepted and queued for processing"
}

POST /api/v1/investor/mint​

Mint tokens to a wallet address.

Request:

{
"walletAddress": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"amount": "1000000000",
"callback": {
"url": "https://your-server.com/mint-callback"
},
"timestamp": 1734567890123
}
FieldRequiredDescription
walletAddressYesDestination wallet address (Ethereum 0x... or Solana base58)
amountYesAmount to mint (raw amount, e.g., in smallest unit). For 6 decimals, "1000000000" = 1000 tokens. Can be string, number, or bigint.
callback.urlYesURL to receive status callbacks
timestampYesRequest timestamp (ms since epoch)

Response (201 Created):

{
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"status": "QUEUED",
"message": "Mint request accepted and queued for processing"
}

GET /api/v1/investor/kyc/:wallet_address​

Read KYC records for a wallet address. Requires Cognito JWT with transfer-agent group membership.

Request Headers:

HeaderRequiredDescription
AuthorizationYesBearer <cognito_access_token>

Example:

curl https://ta-staging.upside.gg/api/v1/investor/kyc/HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH \
-H "Authorization: Bearer YOUR_COGNITO_ACCESS_TOKEN"

Response (200 OK):

{
"success": true,
"walletAddress": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"records": [
{
"subjectId": "investor_12345:HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"account": {
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"dob": "1990-01-15",
"blockchainWallet": { "address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH" }
},
"fileUrls": {
"identityDocument": "https://presigned-s3-url...",
"proofOfAddress": "https://presigned-s3-url..."
}
}
],
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
FieldDescription
recordsArray of KYC records for the wallet (may be empty)
records[].accountInvestor PII data
records[].fileUrlsPresigned S3 URLs for documents (1-minute expiry)

Response (401): Missing or invalid JWT token.

Response (403): Valid JWT but user is not in the transfer-agent group.

GET /api/v1/investor/whitelist_status/:wallet_address​

Check the whitelist status of a wallet address. Requires X-Api-Key header (no signature required).

Request Headers:

HeaderRequiredDescription
X-Api-KeyYesYour API key (Ed25519 public key, base64-encoded)

Example:

curl https://ta-staging.upside.gg/api/v1/investor/whitelist_status/HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH \
-H "X-Api-Key: YOUR_BASE64_PUBLIC_KEY"

Response (200 OK):

{
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"isWhitelisted": true,
"whitelistGroupId": 1,
"walletGroup": 1
}
FieldDescription
isWhitelistedtrue if wallet is in the expected whitelist group
whitelistGroupIdThe expected group from API key configuration
walletGroupThe actual group the wallet belongs to on-chain

POST /api/v1/investor/purchases​

Create an investor purchase session. The API key must have the purchase capability. This request requires X-Api-Key and X-Request-Sig-B64.

Upside hosts the investor steps. Redirect the investor to the returned url.

Request:

{
"externalInvestorId": "investor_12345",
"email": "alice@example.com",
"walletAddress": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"amount": "1000",
"paymentMethod": "usd",
"returnUrl": "https://your-server.com/purchase-return",
"callbackUrl": "https://your-server.com/purchase-callback",
"timestamp": 1734567890123
}
FieldRequiredDescription
externalInvestorIdNoYour identifier for the investor
emailNoInvestor email address
walletAddressNoWallet address to prefill. A wallet that is not on the asset chain is not saved
amountNoInvestment amount as a decimal string. Send this field with paymentMethod to prefill the order. An amount that the offering does not accept is not saved
paymentMethodNousd or usdc. Send this field with amount to prefill the order
returnUrlNoURL that the investor returns to after the session
callbackUrlNoHTTPS URL that receives the purchase result
timestampYesRequest timestamp (ms since epoch)

Response (201 Created):

{
"purchaseId": "pur-1734567890123-a1b2c3d4",
"url": "https://invest-staging.upside.gg/p/SESSION_TOKEN",
"expiresAt": "2026-10-16T12:00:00.000Z"
}
FieldDescription
purchaseIdPurchase session identifier. Use this value to read the status
urlInvestor session URL. Production uses https://invest.upside.gg/p/.... Staging uses https://invest-staging.upside.gg/p/...
expiresAtTime when the session expires (ISO 8601)

Response (400): The offering is inactive.

{
"error": "OFFERING_INACTIVE",
"details": "This asset has no active offering"
}

GET /api/v1/investor/purchases/:purchaseId​

Read the status of a purchase session. Requires X-Api-Key (no signature required). The API key must have the purchase capability.

Example:

curl https://ta-staging.upside.gg/api/v1/investor/purchases/pur-1734567890123-a1b2c3d4 \
-H "X-Api-Key: YOUR_BASE64_PUBLIC_KEY"

Response (200 OK):

{
"purchaseId": "pur-1734567890123-a1b2c3d4",
"status": "minted",
"currentStep": "complete",
"order": {
"tokenAmount": "100",
"subtotal": "1000",
"fee": "10",
"total": "1010",
"currency": "USD",
"paymentMethod": "usd"
},
"wallet": {
"chainFamily": "solana",
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
"verification": "signature"
},
"whitelist": {
"txHash": "5UBsqwhitelistabc123"
},
"mint": {
"txHash": "5UBsqmintabc123"
}
}
FieldDescription
statuscreated, in_progress, paid, whitelisting, whitelisted, minting, minted, failed, cancelled, or expired
currentStepamount, wallet, identity, accreditation, agreement, payment, minting, or complete
orderOrder summary: tokenAmount, subtotal, fee, total, currency, paymentMethod
walletchainFamily (evm, solana, sui), address, and verification (signature or manual)
whitelist.txHashWhitelist transaction hash
mint.txHashMint transaction hash
failurePresent when status is failed: step, code, message

Response (404): The purchase session does not exist.

GET /api/v1/callback_pk​

Get the Ed25519 public key used to sign callback payloads (no authentication required).

Response (200 OK):

Returns the base64-encoded public key as plain text.

curl https://ta-staging.upside.gg/api/v1/callback_pk
# Returns: abc123...base64-encoded-public-key...

Use this key to verify the X-Callback-Sig-B64 header on callbacks.

GET /api/v1/health​

Health check endpoint (no authentication required).

Response (200 OK):

{
"status": "ok",
"timestamp": "2024-12-15T10:30:00.000Z"
}

Callbacks​

When processing completes, a callback is delivered to your specified URL.

Callback Payload​

{
"requestId": "pur-1734567890123-a1b2c3d4",
"status": "SUCCESS",
"reason": null,
"txId": "5UBsq...abc123",
"message": "Investor successfully whitelisted",
"clientRequestSignature": "base64-signature-from-X-Request-Sig-B64-header"
}
FieldDescription
requestIdUnique request identifier
statusSUCCESS, FAILED, or PENDING
reasonError code if failed (see below)
txIdBlockchain transaction ID (on success)
messageHuman-readable status message
clientRequestSignatureYour original X-Request-Sig-B64 header echoed back

Callback Headers​

HeaderDescription
X-Callback-Sig-B64Base64-encoded Ed25519 signature of the canonicalized callback body
Content-Typeapplication/json

Verifying Callback Signatures​

Callbacks are signed using Ed25519. To verify the signature:

  1. Fetch the public key from GET /api/v1/callback_pk (can be cached)
  2. Canonicalize the callback body (JSON with sorted keys)
  3. Verify the X-Callback-Sig-B64 header against the canonicalized body
# Get the callback signer public key
curl https://ta-staging.upside.gg/api/v1/callback_pk

Status Values​

StatusDescriptionFinal?
QUEUEDRequest accepted and queued for processingNo
PENDINGRequest is being processedNo
SUCCESSWhitelisting completed successfullyYes
FAILEDWhitelisting failed (see reason)Yes

Failure Reasons​

ReasonDescriptionRetryable?
INVALID_MESSAGEQueue message malformed or failed validationNo
PII_STORAGE_ERRORFailed to store investor KYC dataYes
ALREADY_WHITELISTEDWallet is already in the target whitelist groupNo
SIGNING_NOT_ALLOWEDAPI key lacks permission for this token/networkNo
SIGNING_ERRORTransaction signing failedYes
BROADCAST_SUBMIT_ERRORFailed to submit transaction to relayerYes
BROADCAST_FAILEDTransaction submitted but failed on-chainMaybe
CALLBACK_DELIVERY_FAILEDCallback couldn't be delivered to your URLN/A
WHITELIST_UNSUPPORTEDAutomated whitelist is not available for this chainNo
MINT_UNSUPPORTEDAutomated mint is not available for this chainNo
OPERATOR_KEY_INVALIDThe offering operator key is missing or not validNo

Automated whitelist and mint are available for Solana assets and for EVM v5 assets. EVM v3 can whitelist but cannot mint, and that mint ends in FAILED with MINT_UNSUPPORTED. Sui ends in FAILED with WHITELIST_UNSUPPORTED.

Purchase Callback Payload​

When a purchase session completes, Upside sends this payload to callbackUrl. requestId is the purchase id. clientRequestSignature is also the purchase id.

{
"requestId": "pur-1734567890123-a1b2c3d4",
"status": "SUCCESS",
"reason": null,
"txId": "5UBsqmintabc123",
"message": "minted",
"clientRequestSignature": "pur-1734567890123-a1b2c3d4"
}
FieldDescription
requestIdPurchase session identifier
statusSUCCESS or FAILED
reasonFailure code when status is FAILED. Includes WHITELIST_UNSUPPORTED, MINT_UNSUPPORTED, OPERATOR_KEY_INVALID, and BROADCAST_FAILED
txIdMint transaction id when status is SUCCESS
messageHuman-readable status message
clientRequestSignaturePurchase session identifier. Use this value to detect a duplicate callback

Verify X-Callback-Sig-B64 with the key from GET /api/v1/callback_pk.

Purchase Status Values​

The status check returns these purchase session values. The callback status is SUCCESS or FAILED.

StatusDescriptionFinal?
createdThe session exists. The investor has not startedNo
in_progressThe investor is completing the stepsNo
paidThe investor has paidNo
whitelistingUpside is whitelisting the walletNo
whitelistedThe wallet is whitelistedNo
mintingUpside is minting the tokensNo
mintedThe tokens are mintedYes
failedThe session failed. Read failure for the step and the codeYes
cancelledThe session is cancelledYes
expiredThe session expiredYes

Handling Callbacks​

import express from 'express';
import { sign } from 'tweetnacl';
import canonicalize from 'canonicalize';

const app = express();

// Fetch this once from GET /api/v1/callback_pk and cache it
const CALLBACK_PUBLIC_KEY = Buffer.from('BASE64_PUBLIC_KEY_FROM_CALLBACK_PK_ENDPOINT', 'base64');

function verifyCallbackSignature(body: object, signatureB64: string): boolean {
const canonicalBody = canonicalize(body);
const signature = Buffer.from(signatureB64, 'base64');
return sign.detached.verify(
Buffer.from(canonicalBody, 'utf-8'),
signature,
CALLBACK_PUBLIC_KEY
);
}

app.post('/whitelist-callback', express.json(), (req, res) => {
// 1. Verify callback signature (recommended)
const signatureHeader = req.headers['x-callback-sig-b64'] as string;
if (!signatureHeader || !verifyCallbackSignature(req.body, signatureHeader)) {
console.error('Invalid callback signature');
return res.status(401).send('Invalid signature');
}

// 2. Check for duplicate using clientRequestSignature (idempotency)
const { clientRequestSignature, status, reason, txId, requestId } = req.body;
if (isAlreadyProcessed(clientRequestSignature)) {
return res.status(200).send('OK');
}

// 3. Process the callback
switch (status) {
case 'SUCCESS':
markAsWhitelisted(requestId, txId);
break;
case 'FAILED':
handleFailure(requestId, reason);
break;
}

// 4. Acknowledge receipt
res.status(200).send('OK');
});

Status Check Fallback​

If you miss a callback, use the status check endpoint:

GET /api/v1/investor/whitelist_status/{wallet_address}

This returns the current on-chain whitelist status (source of truth).

Full Examples​

Whitelisting Example​

import { sign } from 'tweetnacl';
import canonicalize from 'canonicalize';

const API_URL = 'https://ta-staging.upside.gg';
const API_KEY = 'YOUR_BASE64_PUBLIC_KEY';
const SECRET_KEY = Buffer.from('YOUR_BASE64_SECRET_KEY', 'base64');

async function whitelistInvestor(investor: object, callbackUrl: string) {
const timestamp = Date.now();

const body = {
investor,
callback: { url: callbackUrl },
timestamp,
};

// Sign the request
const canonicalBody = canonicalize(body);
const signature = sign.detached(
Buffer.from(canonicalBody, 'utf-8'),
SECRET_KEY
);
const signatureBase64 = Buffer.from(signature).toString('base64');

// Send the request
const response = await fetch(`${API_URL}/api/v1/investor/whitelist`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
'X-Request-Sig-B64': signatureBase64,
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Request failed: ${await response.text()}`);
}

return response.json();
}

// Usage
const result = await whitelistInvestor(
{
id: 'investor_12345',
firstName: 'Alice',
lastName: 'Doe',
email: 'alice@example.com',
dob: '1990-01-15',
fullAddress: {
street: '123 Main St',
city: 'Berlin',
countryCode: 'DE',
postalCode: '10115',
},
tin: '123-45-6789',
blockchainWallet: {
address: 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH',
},
primaryPhone: {
number: '+49123456789',
},
},
'https://your-server.com/callback',
);

Minting Example​

import { sign } from 'tweetnacl';
import canonicalize from 'canonicalize';

const API_URL = 'https://ta-staging.upside.gg';
const API_KEY = 'YOUR_BASE64_PUBLIC_KEY';
const SECRET_KEY = Buffer.from('YOUR_BASE64_SECRET_KEY', 'base64');

async function mintTokens(walletAddress: string, amount: string, callbackUrl: string) {
const timestamp = Date.now();

const body = {
walletAddress,
amount,
callback: { url: callbackUrl },
timestamp,
};

// Sign the request
const canonicalBody = canonicalize(body);
const signature = sign.detached(
Buffer.from(canonicalBody, 'utf-8'),
SECRET_KEY
);
const signatureBase64 = Buffer.from(signature).toString('base64');

// Send the request
const response = await fetch(`${API_URL}/api/v1/investor/mint`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
'X-Request-Sig-B64': signatureBase64,
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Request failed: ${await response.text()}`);
}

return response.json();
}

// Usage - mint 1000 tokens (with 6 decimals, so 1000000000 raw units)
const result = await mintTokens(
'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH',
'1000000000',
'https://your-server.com/mint-callback',
);

Purchase Session Example​

import express from 'express';
import { sign } from 'tweetnacl';
import canonicalize from 'canonicalize';

const API_URL = 'https://ta-staging.upside.gg';
const API_KEY = 'YOUR_BASE64_PUBLIC_KEY';
const SECRET_KEY = Buffer.from('YOUR_BASE64_SECRET_KEY', 'base64');

function signBody(body: object): string {
const canonicalBody = canonicalize(body);
const signature = sign.detached(
Buffer.from(canonicalBody, 'utf-8'),
SECRET_KEY
);
return Buffer.from(signature).toString('base64');
}

async function createPurchaseSession() {
const body = {
externalInvestorId: 'investor_12345',
email: 'alice@example.com',
walletAddress: 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH',
amount: '1000',
paymentMethod: 'usd',
returnUrl: 'https://your-server.com/purchase-return',
callbackUrl: 'https://your-server.com/purchase-callback',
timestamp: Date.now(),
};

const response = await fetch(`${API_URL}/api/v1/investor/purchases`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
'X-Request-Sig-B64': signBody(body),
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Request failed: ${await response.text()}`);
}

const created = await response.json();
// Redirect the investor to created.url.
// Production: https://invest.upside.gg/p/...
// Staging: https://invest-staging.upside.gg/p/...
return created;
}

async function getPurchaseStatus(purchaseId: string) {
const response = await fetch(
`${API_URL}/api/v1/investor/purchases/${purchaseId}`,
{
headers: { 'X-Api-Key': API_KEY },
}
);

if (!response.ok) {
throw new Error(`Request failed: ${await response.text()}`);
}

return response.json();
}

// Use the same callback public key as the whitelist example.
const app = express();

app.post('/purchase-callback', express.json(), async (req, res) => {
// verifyCallbackSignature is the function in Handling Callbacks.
const signatureHeader = req.headers['x-callback-sig-b64'] as string;
if (!signatureHeader || !verifyCallbackSignature(req.body, signatureHeader)) {
return res.status(401).send('Invalid signature');
}

const { requestId, status, reason, txId, clientRequestSignature } = req.body;
if (isAlreadyProcessed(clientRequestSignature)) {
return res.status(200).send('OK');
}

if (status === 'SUCCESS') {
markPurchaseMinted(requestId, txId);
} else {
handlePurchaseFailure(requestId, reason);
}

res.status(200).send('OK');
});

// Read the status when the callback does not arrive.
const status = await getPurchaseStatus('pur-1734567890123-a1b2c3d4');

Next Steps​