Agent Skills: Navan SDK Patterns

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/navan-sdk-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/navan-pack/skills/navan-sdk-patterns

Skill Files

Browse the full folder contents for navan-sdk-patterns.

Download Skill

Loading file tree…

plugins/saas-packs/navan-pack/skills/navan-sdk-patterns/SKILL.md

Skill Metadata

Name
navan-sdk-patterns
Description
|

Navan SDK Patterns

Overview

Build a typed API wrapper around Navan REST endpoints since no official SDK exists (@navan/sdk is not a real package). These patterns provide automatic token lifecycle management, typed responses, retry middleware, and centralized error handling.

Purpose: Create a reusable NavanAPI class encapsulating authentication, request handling, and error recovery.

Prerequisites

  • Completed navan-install-auth with working OAuth 2.0 credentials
  • TypeScript 5+ project with dotenv installed
  • Familiarity with the Navan endpoints from navan-hello-world

Instructions

Step 1: Define Response Interfaces

Type the known API response shapes so every call returns structured data:

// navan-types.ts
export interface NavanTrip {
  uuid: string;
  traveler_name: string;
  origin: string;
  destination: string;
  departure_date: string;
  return_date: string;
  booking_status: string;
  booking_type: 'flight' | 'hotel' | 'car';
}

export interface NavanUser {
  id: string;
  email: string;
  first_name: string;
  last_name: string;
  department: string;
  role: string;
}

export interface NavanApiError {
  status: number;
  message: string;
  endpoint: string;
  timestamp: string;
}

Step 2: Build the NavanAPI Wrapper Class

Create a singleton client with automatic token management:

// navan-client.ts
import 'dotenv/config';
import type { NavanTrip, NavanUser, NavanApiError } from './navan-types';

export class NavanAPI {
  private baseUrl: string;
  private clientId: string;
  private clientSecret: string;
  private accessToken: string | null = null;
  private tokenExpiry: number = 0;

  constructor() {
    this.baseUrl = process.env.NAVAN_BASE_URL ?? 'https://api.navan.com';
    this.clientId = process.env.NAVAN_CLIENT_ID ?? '';
    this.clientSecret = process.env.NAVAN_CLIENT_SECRET ?? '';
    if (!this.clientId || !this.clientSecret) {
      throw new Error('NAVAN_CLIENT_ID and NAVAN_CLIENT_SECRET must be set');
    }
  }

  /** Acquire or refresh the OAuth 2.0 bearer token */
  private async authenticate(): Promise<string> {
    if (this.accessToken && Date.now() < this.tokenExpiry) {
      return this.accessToken;
    }

    const response = await fetch(`${this.baseUrl}/ta-auth/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
      }),
    });

    if (!response.ok) {
      throw this.toApiError(response.status, 'Authentication failed', '/ta-auth/oauth/token');
    }

    const data = await response.json();
    this.accessToken = data.access_token;
    // Buffer 60 seconds before actual expiry
    this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
    return this.accessToken!;
  }

  /** Core request method with auth, retries, and error handling */
  private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const token = await this.authenticate();
    const url = `${this.baseUrl}${endpoint}`;

    for (let attempt = 0; attempt < 3; attempt++) {
      const response = await fetch(url, {
        ...options,
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });

      if (response.ok) return response.json() as Promise<T>;

      // Retry on 429 (rate limit) and 503 (maintenance)
      if ((response.status === 429 || response.status === 503) && attempt < 2) {
        const delay = Math.pow(2, attempt + 1) * 1000; // 2s, 4s
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }

      // Re-auth on 401 (expired token)
      if (response.status === 401 && attempt < 2) {
        this.accessToken = null;
        this.tokenExpiry = 0;
        continue;
      }

      throw this.toApiError(response.status, await response.text(), endpoint);
    }

    throw this.toApiError(0, 'Max retries exceeded', endpoint);
  }

  private toApiError(status: number, message: string, endpoint: string): NavanApiError {
    return { status, message, endpoint, timestamp: new Date().toISOString() };
  }

  // --- Public API methods ---

  async getBookings(page = 0, size = 50): Promise<NavanTrip[]> {
    const data = await this.request<{ data: NavanTrip[] }>(`/v1/bookings?page=${page}&size=${size}`);
    return data.data ?? [];
  }

  async getUsers(): Promise<NavanUser[]> {
    const data = await this.request<{ data: NavanUser[] }>('/v1/users');
    return data.data ?? [];
  }
}

Step 3: Implement a Singleton Factory

Ensure one client instance per process to reuse the token:

// navan-singleton.ts
import { NavanAPI } from './navan-client';

let instance: NavanAPI | null = null;

export function getNavanClient(): NavanAPI {
  if (!instance) instance = new NavanAPI();
  return instance;
}

// Usage
const navan = getNavanClient();
const bookings = await navan.getBookings();
const users = await navan.getUsers();

Step 4: Add Error Handling Middleware

Wrap API calls with structured error handling for calling code:

// navan-safe.ts
import type { NavanApiError } from './navan-types';

type Result<T> = { ok: true; data: T } | { ok: false; error: NavanApiError };

export async function safeCall<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try {
    const data = await fn();
    return { ok: true, data };
  } catch (err) {
    const apiError = err as NavanApiError;
    console.error(`Navan API error [${apiError.status}] ${apiError.endpoint}: ${apiError.message}`);
    return { ok: false, error: apiError };
  }
}

// Usage
const result = await safeCall(() => navan.getUserTrips());
if (result.ok) {
  console.log(`Found ${result.data.length} trips`);
} else {
  console.error(`Failed: ${result.error.message}`);
}

Step 5: Python Equivalent Pattern

# navan_client.py
import os
import time
import requests
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()

@dataclass
class NavanAPIError(Exception):
    status: int
    message: str
    endpoint: str

class NavanAPI:
    def __init__(self):
        self.base_url = os.environ.get("NAVAN_BASE_URL", "https://api.navan.com")
        self.client_id = os.environ["NAVAN_CLIENT_ID"]
        self.client_secret = os.environ["NAVAN_CLIENT_SECRET"]
        self._token: str | None = None
        self._token_expiry: float = 0

    def _authenticate(self) -> str:
        if self._token and time.time() < self._token_expiry:
            return self._token
        resp = requests.post(f"{self.base_url}/ta-auth/oauth/token", data={
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
        })
        resp.raise_for_status()
        data = resp.json()
        self._token = data["access_token"]
        self._token_expiry = time.time() + data.get("expires_in", 3600) - 60
        return self._token

    def _request(self, method: str, endpoint: str, **kwargs) -> dict:
        token = self._authenticate()
        for attempt in range(3):
            resp = requests.request(method, f"{self.base_url}{endpoint}",
                headers={"Authorization": f"Bearer {token}"}, **kwargs)
            if resp.ok:
                return resp.json()
            if resp.status_code in (429, 503) and attempt < 2:
                time.sleep(2 ** (attempt + 1))
                continue
            if resp.status_code == 401 and attempt < 2:
                self._token = None
                token = self._authenticate()
                continue
            raise NavanAPIError(resp.status_code, resp.text, endpoint)
        raise NavanAPIError(0, "Max retries exceeded", endpoint)

    def get_bookings(self, page: int = 0, size: int = 50) -> list[dict]:
        return self._request("GET", f"/v1/bookings?page={page}&size={size}").get("data", [])

    def get_users(self) -> list[dict]:
        return self._request("GET", "/v1/users").get("data", [])

Output

Successful implementation produces:

  • A typed NavanAPI class with automatic token refresh and retry logic
  • Response interfaces for trips, users, and errors
  • A singleton factory for client reuse across the application
  • A safeCall wrapper for structured error handling in calling code

Error Handling

| Error | Code | Cause | Solution | |-------|------|-------|----------| | Invalid credentials | 401 | Expired token or wrong secrets | Client auto-retries with fresh token; check .env | | Forbidden | 403 | Insufficient permissions or wrong tier | Verify admin role; check Navan plan tier | | Not found | 404 | Invalid endpoint path | Verify endpoint against known paths in this guide | | Rate limited | 429 | Too many requests | Client auto-retries with exponential backoff | | Server error | 500 | Navan service issue | Client auto-retries; escalate if persistent | | Maintenance | 503 | Navan downtime | Client auto-retries; check for scheduled windows |

Examples

Fetch all bookings with error handling:

const navan = getNavanClient();
const result = await safeCall(() => navan.getBookings());
if (result.ok) {
  const flights = result.data.filter((t) => t.booking_type === 'flight');
  console.log(`${flights.length} flight bookings found`);
}

Resources

Next Steps

With your typed wrapper in place, see navan-common-errors for a comprehensive error reference, or navan-local-dev-loop to set up token caching and request logging for development.