API Design

Building robust APIs for Solana applications requires handling blockchain-specific concerns like transaction states, async operations, and data consistency.

API Architecture

Text
┌─────────────────────────────────────────────────────────────────┐
│                    API Architecture                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Client                                                         │
│    │                                                            │
│    ▼                                                            │
│  ┌────────────────────────────────────────────────────────┐     │
│  │                    API Gateway                          │     │
│  │  • Rate LimitingAuthValidationLogging      │     │
│  └────────────────────────────────────────────────────────┘     │
│    │                                                            │
│    ├──────────────────┬──────────────────┐                      │
│    ▼                  ▼                  ▼                      │
│  ┌────────┐      ┌────────┐      ┌────────┐                     │
│  │  REST  │      │GraphQL │      │WebSocket│                    │
│  │  API   │      │  API   │      │   API   │                    │
│  └────────┘      └────────┘      └────────┘                     │
│    │                  │                  │                      │
│    └──────────────────┼──────────────────┘                      │
│                       │                                         │
│    ┌──────────────────┼──────────────────┐                      │
│    ▼                  ▼                  ▼                      │
│  ┌────────┐      ┌────────┐      ┌────────┐                     │
│  │Solana  │      │Database│      │ Cache  │                     │
│  │  RPC   │      │        │      │(Redis) │                     │
│  └────────┘      └────────┘      └────────┘                     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

REST API Design

TypeScript
import express from "express";
import { Connection, PublicKey, Transaction } from "@solana/web3.js";
import { Pool } from "pg";
import Redis from "ioredis";
import { z } from "zod";

const app = express();
app.use(express.json());

// Dependency injection
interface AppContext {
  connection: Connection;
  db: Pool;
  redis: Redis;
}

const ctx: AppContext = {
  connection: new Connection(process.env.RPC_URL!),
  db: new Pool({ connectionString: process.env.DATABASE_URL }),
  redis: new Redis(process.env.REDIS_URL),
};

// ==================== Validation Schemas ====================

const AddressSchema = z.string().refine(
  (val) => {
    try {
      new PublicKey(val);
      return true;
    } catch {
      return false;
    }
  },
  { message: "Invalid Solana address" },
);

const PaginationSchema = z.object({
  limit: z.coerce.number().min(1).max(100).default(20),
  cursor: z.string().optional(),
});

const TransferSchema = z.object({
  to: AddressSchema,
  amount: z.number().positive(),
  currency: z.enum(["SOL", "USDC"]),
});

// ==================== Middleware ====================

// Error handling middleware
function errorHandler(err: any, req: any, res: any, next: any) {
  console.error(err);

  if (err instanceof z.ZodError) {
    return res.status(400).json({
      error: "Validation error",
      details: err.errors,
    });
  }

  if (err.name === "NotFoundError") {
    return res.status(404).json({ error: err.message });
  }

  res.status(500).json({ error: "Internal server error" });
}

// Request validation middleware
function validate(schema: z.ZodType) {
  return (req: any, res: any, next: any) => {
    try {
      req.validated = schema.parse(req.body);
      next();
    } catch (err) {
      next(err);
    }
  };
}

// Caching middleware
function cache(ttl: number) {
  return async (req: any, res: any, next: any) => {
    const key = `cache:${req.originalUrl}`;
    const cached = await ctx.redis.get(key);

    if (cached) {
      return res.json(JSON.parse(cached));
    }

    // Store original json method
    const originalJson = res.json.bind(res);
    res.json = (data: any) => {
      ctx.redis.setex(key, ttl, JSON.stringify(data));
      return originalJson(data);
    };

    next();
  };
}

// ==================== Routes ====================

// Get account info
app.get("/api/accounts/:address", cache(30), async (req, res, next) => {
  try {
    const address = AddressSchema.parse(req.params.address);
    const pubkey = new PublicKey(address);

    const [accountInfo, balance] = await Promise.all([
      ctx.connection.getAccountInfo(pubkey),
      ctx.connection.getBalance(pubkey),
    ]);

    if (!accountInfo) {
      throw { name: "NotFoundError", message: "Account not found" };
    }

    res.json({
      address,
      lamports: balance,
      owner: accountInfo.owner.toBase58(),
      executable: accountInfo.executable,
      rentEpoch: accountInfo.rentEpoch,
      dataLength: accountInfo.data.length,
    });
  } catch (err) {
    next(err);
  }
});

// Get token accounts
app.get("/api/accounts/:address/tokens", cache(30), async (req, res, next) => {
  try {
    const address = AddressSchema.parse(req.params.address);
    const pubkey = new PublicKey(address);

    const tokenAccounts = await ctx.connection.getParsedTokenAccountsByOwner(
      pubkey,
      {
        programId: new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
      },
    );

    const tokens = tokenAccounts.value.map((ta) => ({
      address: ta.pubkey.toBase58(),
      mint: ta.account.data.parsed.info.mint,
      balance: ta.account.data.parsed.info.tokenAmount.uiAmount,
      decimals: ta.account.data.parsed.info.tokenAmount.decimals,
    }));

    res.json({ tokens });
  } catch (err) {
    next(err);
  }
});

// Get transaction history
app.get("/api/accounts/:address/transactions", async (req, res, next) => {
  try {
    const address = AddressSchema.parse(req.params.address);
    const { limit, cursor } = PaginationSchema.parse(req.query);
    const pubkey = new PublicKey(address);

    const signatures = await ctx.connection.getSignaturesForAddress(pubkey, {
      limit,
      before: cursor,
    });

    const transactions = await Promise.all(
      signatures.map(async (sig) => {
        const tx = await ctx.connection.getParsedTransaction(sig.signature, {
          maxSupportedTransactionVersion: 0,
        });

        return {
          signature: sig.signature,
          slot: sig.slot,
          blockTime: sig.blockTime,
          err: sig.err,
          memo: sig.memo,
          fee: tx?.meta?.fee,
        };
      }),
    );

    const nextCursor =
      signatures.length === limit
        ? signatures[signatures.length - 1].signature
        : null;

    res.json({
      transactions,
      pagination: {
        limit,
        nextCursor,
        hasMore: nextCursor !== null,
      },
    });
  } catch (err) {
    next(err);
  }
});

// Create transfer transaction (returns unsigned transaction)
app.post(
  "/api/transactions/transfer",
  validate(TransferSchema),
  async (req, res, next) => {
    try {
      const { to, amount, currency } = req.validated;
      const from = new PublicKey(req.user.address);

      const { blockhash, lastValidBlockHeight } =
        await ctx.connection.getLatestBlockhash();

      const transaction = new Transaction({
        recentBlockhash: blockhash,
        feePayer: from,
      });

      if (currency === "SOL") {
        transaction.add(
          SystemProgram.transfer({
            fromPubkey: from,
            toPubkey: new PublicKey(to),
            lamports: amount * 1e9,
          }),
        );
      } else {
        // USDC transfer
        const usdcMint = new PublicKey(process.env.USDC_MINT!);
        const fromAta = await getAssociatedTokenAddress(usdcMint, from);
        const toAta = await getAssociatedTokenAddress(
          usdcMint,
          new PublicKey(to),
        );

        transaction.add(
          createTransferInstruction(fromAta, toAta, from, amount * 1e6),
        );
      }

      // Serialize for client signing
      const serialized = transaction
        .serialize({ requireAllSignatures: false })
        .toString("base64");

      // Create pending transaction record
      const txId = crypto.randomUUID();
      await ctx.db.query(
        `INSERT INTO pending_transactions (id, user_address, to_address, amount, currency, blockhash, expires_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
        [
          txId,
          from.toBase58(),
          to,
          amount,
          currency,
          blockhash,
          new Date(Date.now() + 60000), // 60 second expiry
        ],
      );

      res.json({
        id: txId,
        transaction: serialized,
        blockhash,
        lastValidBlockHeight,
        expiresAt: new Date(Date.now() + 60000).toISOString(),
      });
    } catch (err) {
      next(err);
    }
  },
);

// Submit signed transaction
app.post("/api/transactions/:id/submit", async (req, res, next) => {
  try {
    const { id } = req.params;
    const { signedTransaction } = req.body;

    // Get pending transaction
    const result = await ctx.db.query(
      "SELECT * FROM pending_transactions WHERE id = $1 AND user_address = $2",
      [id, req.user.address],
    );

    if (result.rows.length === 0) {
      throw { name: "NotFoundError", message: "Transaction not found" };
    }

    const pending = result.rows[0];
    if (new Date() > new Date(pending.expires_at)) {
      throw new Error("Transaction expired");
    }

    // Decode and send
    const buffer = Buffer.from(signedTransaction, "base64");
    const signature = await ctx.connection.sendRawTransaction(buffer, {
      skipPreflight: false,
      preflightCommitment: "confirmed",
    });

    // Update record
    await ctx.db.query(
      `UPDATE pending_transactions SET signature = $1, status = 'submitted' WHERE id = $2`,
      [signature, id],
    );

    // Start confirmation tracking
    trackConfirmation(id, signature);

    res.json({
      id,
      signature,
      status: "submitted",
    });
  } catch (err) {
    next(err);
  }
});

// Get transaction status
app.get("/api/transactions/:id", async (req, res, next) => {
  try {
    const { id } = req.params;

    const result = await ctx.db.query(
      "SELECT * FROM pending_transactions WHERE id = $1",
      [id],
    );

    if (result.rows.length === 0) {
      throw { name: "NotFoundError", message: "Transaction not found" };
    }

    const tx = result.rows[0];

    // If submitted, check on-chain status
    if (tx.signature && tx.status === "submitted") {
      const status = await ctx.connection.getSignatureStatus(tx.signature);

      return res.json({
        id: tx.id,
        signature: tx.signature,
        status: status?.value?.confirmationStatus ?? "pending",
        confirmations: status?.value?.confirmations,
        slot: status?.value?.slot,
        error: status?.value?.err,
      });
    }

    res.json({
      id: tx.id,
      status: tx.status,
      signature: tx.signature,
    });
  } catch (err) {
    next(err);
  }
});

// Track confirmation in background
async function trackConfirmation(id: string, signature: string) {
  try {
    const confirmation = await ctx.connection.confirmTransaction(
      signature,
      "confirmed",
    );

    await ctx.db.query(
      `UPDATE pending_transactions SET status = $1 WHERE id = $2`,
      [confirmation.value.err ? "failed" : "confirmed", id],
    );
  } catch (error) {
    await ctx.db.query(
      `UPDATE pending_transactions SET status = 'failed' WHERE id = $1`,
      [id],
    );
  }
}

app.use(errorHandler);

WebSocket API

TypeScript
import WebSocket from "ws";
import { Connection, PublicKey } from "@solana/web3.js";

interface WSMessage {
  type: string;
  id?: string;
  data?: any;
}

interface Subscription {
  type: "account" | "transaction" | "program";
  address: string;
  subscriptionId: number;
}

export class WebSocketAPI {
  private wss: WebSocket.Server;
  private connection: Connection;
  private subscriptions: Map<WebSocket, Subscription[]> = new Map();

  constructor(server: any, connection: Connection) {
    this.connection = connection;
    this.wss = new WebSocket.Server({ server, path: "/ws" });

    this.wss.on("connection", (ws) => this.handleConnection(ws));
  }

  private handleConnection(ws: WebSocket) {
    console.log("Client connected");
    this.subscriptions.set(ws, []);

    ws.on("message", async (data) => {
      try {
        const message: WSMessage = JSON.parse(data.toString());
        await this.handleMessage(ws, message);
      } catch (error) {
        this.send(ws, { type: "error", data: "Invalid message format" });
      }
    });

    ws.on("close", () => {
      this.cleanup(ws);
    });

    // Send welcome message
    this.send(ws, { type: "connected", data: { version: "1.0" } });
  }

  private async handleMessage(ws: WebSocket, message: WSMessage) {
    switch (message.type) {
      case "subscribe":
        await this.handleSubscribe(ws, message);
        break;
      case "unsubscribe":
        await this.handleUnsubscribe(ws, message);
        break;
      case "ping":
        this.send(ws, { type: "pong", id: message.id });
        break;
      default:
        this.send(ws, { type: "error", data: "Unknown message type" });
    }
  }

  private async handleSubscribe(ws: WebSocket, message: WSMessage) {
    const { channel, address } = message.data;
    const pubkey = new PublicKey(address);
    const subs = this.subscriptions.get(ws)!;

    let subscriptionId: number;

    switch (channel) {
      case "account":
        subscriptionId = this.connection.onAccountChange(
          pubkey,
          (accountInfo, context) => {
            this.send(ws, {
              type: "accountUpdate",
              data: {
                address,
                slot: context.slot,
                lamports: accountInfo.lamports,
                owner: accountInfo.owner.toBase58(),
                data: accountInfo.data.toString("base64"),
              },
            });
          },
          "confirmed",
        );
        break;

      case "logs":
        subscriptionId = this.connection.onLogs(
          pubkey,
          (logs, context) => {
            this.send(ws, {
              type: "logs",
              data: {
                address,
                slot: context.slot,
                signature: logs.signature,
                logs: logs.logs,
                err: logs.err,
              },
            });
          },
          "confirmed",
        );
        break;

      case "signature":
        subscriptionId = this.connection.onSignature(
          address, // signature string
          (result, context) => {
            this.send(ws, {
              type: "signatureUpdate",
              data: {
                signature: address,
                slot: context.slot,
                err: result.err,
              },
            });
          },
          "confirmed",
        );
        break;

      default:
        return this.send(ws, { type: "error", data: "Unknown channel" });
    }

    subs.push({ type: channel, address, subscriptionId });

    this.send(ws, {
      type: "subscribed",
      id: message.id,
      data: { channel, address, subscriptionId },
    });
  }

  private async handleUnsubscribe(ws: WebSocket, message: WSMessage) {
    const { subscriptionId } = message.data;
    const subs = this.subscriptions.get(ws)!;

    const index = subs.findIndex((s) => s.subscriptionId === subscriptionId);
    if (index === -1) {
      return this.send(ws, { type: "error", data: "Subscription not found" });
    }

    const sub = subs[index];
    await this.removeSubscription(sub);
    subs.splice(index, 1);

    this.send(ws, {
      type: "unsubscribed",
      id: message.id,
      data: { subscriptionId },
    });
  }

  private async removeSubscription(sub: Subscription) {
    switch (sub.type) {
      case "account":
        await this.connection.removeAccountChangeListener(sub.subscriptionId);
        break;
      case "transaction":
        await this.connection.removeSignatureListener(sub.subscriptionId);
        break;
      case "program":
        await this.connection.removeOnLogsListener(sub.subscriptionId);
        break;
    }
  }

  private async cleanup(ws: WebSocket) {
    const subs = this.subscriptions.get(ws);
    if (subs) {
      for (const sub of subs) {
        await this.removeSubscription(sub);
      }
    }
    this.subscriptions.delete(ws);
  }

  private send(ws: WebSocket, message: WSMessage) {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(message));
    }
  }

  // Broadcast to all clients subscribed to an address
  broadcast(address: string, message: WSMessage) {
    for (const [ws, subs] of this.subscriptions) {
      if (subs.some((s) => s.address === address)) {
        this.send(ws, message);
      }
    }
  }
}

Rate Limiting

TypeScript
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

// Basic rate limiter
export const basicLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
});

// Strict limiter for write operations
export const writeLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 20,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  keyGenerator: (req) => req.user?.address || req.ip,
});

// Dynamic rate limiting based on tier
export function tierBasedLimiter(getTier: (req: any) => string) {
  const limits: Record<string, number> = {
    free: 100,
    basic: 500,
    pro: 2000,
    enterprise: 10000,
  };

  return rateLimit({
    windowMs: 60 * 1000,
    max: (req) => limits[getTier(req)] || 100,
    store: new RedisStore({
      sendCommand: (...args: string[]) => redis.call(...args),
    }),
  });
}

OpenAPI Documentation

YAML
openapi: 3.0.0
info:
  title: Solana API
  version: 1.0.0
  description: REST API for Solana blockchain interactions

servers:
  - url: https://api.example.com/v1

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Account:
      type: object
      properties:
        address:
          type: string
          example: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
        lamports:
          type: integer
          format: int64
        owner:
          type: string
        executable:
          type: boolean
        dataLength:
          type: integer

    TokenAccount:
      type: object
      properties:
        address:
          type: string
        mint:
          type: string
        balance:
          type: number
        decimals:
          type: integer

    TransactionRequest:
      type: object
      required:
        - to
        - amount
        - currency
      properties:
        to:
          type: string
          description: Recipient address
        amount:
          type: number
          minimum: 0
        currency:
          type: string
          enum: [SOL, USDC]

    TransactionResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        transaction:
          type: string
          description: Base64 encoded transaction
        blockhash:
          type: string
        expiresAt:
          type: string
          format: date-time

    Error:
      type: object
      properties:
        error:
          type: string
        details:
          type: array
          items:
            type: object

paths:
  /accounts/{address}:
    get:
      summary: Get account information
      tags: [Accounts]
      parameters:
        - name: address
          in: path
          required: true
          schema:
            type: string
      responses:
        200:
          description: Account information
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        404:
          description: Account not found

  /accounts/{address}/tokens:
    get:
      summary: Get token accounts
      tags: [Accounts]
      parameters:
        - name: address
          in: path
          required: true
          schema:
            type: string
      responses:
        200:
          description: List of token accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items:
                      $ref: "#/components/schemas/TokenAccount"

  /transactions/transfer:
    post:
      summary: Create transfer transaction
      tags: [Transactions]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransactionRequest"
      responses:
        200:
          description: Unsigned transaction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionResponse"
        400:
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

Best Practices

AreaPractice
VersioningUse URL path versioning (/api/v1/)
PaginationCursor-based pagination for large datasets
CachingCache immutable data (confirmed transactions)
IdempotencyUse idempotency keys for write operations
Error handlingReturn consistent error format with codes
DocumentationOpenAPI spec with examples

Next: Database Patterns - Storing and querying blockchain data.