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.

Whitelist Status Check

Query the current whitelist status of any wallet address.

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

Request Headers

HeaderRequiredDescription
X-Api-KeyYesYour API key (Ed25519 public key, base64-encoded)
X-Request-Sig-B64POST onlyBase64-encoded Ed25519 signature of the canonicalized request body

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 is assigned to an asset and can be used to whitelist and KYC investors for that asset.

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.

API Endpoints

POST /api/v1/investor/whitelist

Submit an investor for whitelisting.

Request:

{
"investor": {
"id": "investor_12345",
"first_name": "Alice",
"last_name": "Doe",
"email": "alice@example.com",
"dob": "1990-01-15",
"full_address": {
"street": "123 Main St",
"city": "Berlin",
"region": "BE",
"countryCode": "DE",
"postalCode": "10115"
},
"tin": "123-45-6789",
"blockchain_wallet": {
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH"
},
"primary_phone": {
"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
callback.urlYesURL to receive status callbacks
timestampYesRequest timestamp (ms since epoch)
verifiedStatusNoKYC verification status (default: true)
verifiedAtNoKYC verification timestamp ISO 8601 (default: current time)

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:

{
"account": {
"id": "investor_12345",
"first_name": "Alice",
"last_name": "Doe",
"email": "alice@example.com",
"dob": "1990-01-15",
"full_address": {
"street": "123 Main St",
"city": "Berlin",
"countryCode": "DE",
"postalCode": "10115"
},
"tin": "123-45-6789",
"blockchain_wallet": {
"address": "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH"
},
"primary_phone": {
"number": "+49123456789"
}
},
"callback": {
"url": "https://your-server.com/kyc-callback"
},
"timestamp": 1734567890123,
"verifiedStatus": true,
"verifiedAt": "2024-01-15T10:30:00.000Z"
}
FieldRequiredDescription
accountYesAccount/investor 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"
}

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

Check the whitelist status of a wallet address.

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

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": "550e8400-e29b-41d4-a716-446655440000",
"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 callback body
Content-Typeapplication/json

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

Handling Callbacks

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

const app = express();
const CALLBACK_PUBLIC_KEY = Buffer.from('UPSIDE_CALLBACK_PUBLIC_KEY', 'base64');

app.post('/whitelist-callback', express.json(), (req, res) => {
// 1. Verify callback signature (optional but recommended)
const signature = req.headers['x-callback-sig-b64'];
// ... verify signature using CALLBACK_PUBLIC_KEY

// 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 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',
first_name: 'Alice',
last_name: 'Doe',
email: 'alice@example.com',
dob: '1990-01-15',
full_address: {
street: '123 Main St',
city: 'Berlin',
countryCode: 'DE',
postalCode: '10115',
},
tin: '123-45-6789',
blockchain_wallet: {
address: 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH',
},
primary_phone: {
number: '+49123456789',
},
},
'https://your-server.com/callback',
);

Next Steps