A type-safe TypeScript client for the EVE Online ESI API.
- Typed responses for all endpoints
- ETag caching with Cache-Control TTL, stale-on-error, and write invalidation
- Automatic offset-based pagination and cursor-based pagination support
- Rate limiting with header-driven backoff
- Automatic token refresh with 401 retry and concurrent coalescing
- 32 domain clients covering the full ESI surface
npm install @lgriffin/esi.tsgit clone https://github.com/lgriffin/ESI.ts.git
cd ESI.ts
npm install # installs dependencies and compiles (via the prepare script)If you've already installed and just need to recompile:
npm run buildVerify everything works:
npm run example:status # quick smoke test — checks ESI is reachable
npm test # run the full test suiteimport { EsiClient } from '@lgriffin/esi.ts';
const client = new EsiClient();
// Public data — no auth required
const alliances = await client.alliance.getAlliances();
const character = await client.characters.getCharacterPublicInfo(1689391488);
const system = await client.universe.getSystemById(30000142);
const prices = await client.market.getMarketPrices();
// Authenticated data — token read from ESI_ACCESS_TOKEN env var
const authedClient = new EsiClient();
const assets = await authedClient.assets.getCharacterAssets(characterId);
const wallet = await authedClient.wallet.getCharacterWallet(characterId);
// Clean up when done
await client.shutdown();const client = new EsiClient({
clientId: 'my-app', // User-Agent identifier (default: 'esi-client')
accessToken: 'your-token', // EVE SSO token for authenticated endpoints
baseUrl: 'https://esi.evetech.net', // ESI base URL (default)
onTokenRefresh: async () => newToken, // Auto-refresh on 401 (optional)
timeout: 30000, // Request timeout in ms (default: 30000)
retryAttempts: 3, // Retry count (default: 3)
enableETagCache: true, // ETag caching (default: true)
etagCacheConfig: {
maxEntries: 1000, // Max cached responses (default: 1000)
defaultTtl: 300000, // Fallback TTL in ms (default: 5 min)
cleanupInterval: 60000, // Expired entry cleanup interval (default: 1 min)
},
});The access token can be updated at runtime:
client.setAccessToken('new-token');Many ESI endpoints require an EVE SSO access token. There are three ways to provide one:
Set ESI_ACCESS_TOKEN in your environment or a .env file. The client reads it automatically — no token in source code.
# Copy the example and fill in your token
cp .env.example .envESI_ACCESS_TOKEN=your-eve-sso-access-token
ESI_CLIENT_ID=my-app-nameIf you use a .env loader like dotenv, load it before creating the client:
import 'dotenv/config';
import { EsiClient } from '@lgriffin/esi.ts';
const client = new EsiClient();
// Token is picked up from process.env.ESI_ACCESS_TOKENPass the token directly (useful for apps that manage tokens themselves):
const client = new EsiClient({ accessToken: token });Set or refresh the token after construction:
client.setAccessToken(newToken);- Register an application at EVE Developers
- Set a callback URL and select the ESI scopes your app needs
- Implement the OAuth2 flow to obtain an access token
- Access tokens expire — use the refresh token to get new ones
EVE SSO access tokens expire after 20 minutes. Instead of manually tracking expiry, you can provide a refresh callback — the client will automatically call it on 401, update the token, and retry the request:
const client = new EsiClient({
accessToken: initialToken,
onTokenRefresh: async () => {
const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: myRefreshToken,
client_id: myClientId,
}),
});
const { access_token } = await response.json();
return access_token;
},
});
// Requests now auto-refresh on 401 — no manual token management needed
const location = await client.location.getCharacterLocation(characterId);The token provider can also be set or changed at runtime:
client.setTokenProvider(myRefreshFunction);
client.setTokenProvider(undefined); // disable auto-refreshKey behaviors:
- Only retries once per request — if the refreshed token also gets a 401, the error is thrown
- Concurrent coalescing — if multiple requests hit 401 simultaneously, only one refresh call is made
- If the refresh callback throws (e.g., refresh token revoked), a
TOKEN_REFRESH_FAILEDerror is raised - Without a token provider, 401 errors throw immediately as before
| Variable | Description | Default |
|---|---|---|
ESI_ACCESS_TOKEN |
EVE SSO access token | none |
ESI_CLIENT_ID |
User-Agent identifier | esi-client |
ESI_BASE_URL |
ESI API base URL | https://esi.evetech.net |
ESI_LOG_LEVEL |
Log level (error, warn, info, debug) |
warn |
All clients are accessed as properties on the EsiClient instance. Authenticated endpoints require an access token.
| Client | Property | Auth | Examples |
|---|---|---|---|
| Alliance | client.alliance |
Some | getAlliances(), getAllianceById(id) |
| Assets | client.assets |
Yes | getCharacterAssets(id) |
| Calendar | client.calendar |
Yes | getCharacterCalendar(id) |
| Characters | client.characters |
Some | getCharacterPublicInfo(id), getCharacterPortrait(id) |
| Clones | client.clones |
Yes | getCharacterClones(id) |
| Contacts | client.contacts |
Yes | getCharacterContacts(id) |
| Contracts | client.contracts |
Yes | getCharacterContracts(id) |
| Corporations | client.corporations |
Some | getCorporationInfo(id), getCorporationMembers(id) |
| Dogma | client.dogma |
No | getDogmaAttributes(), getDogmaEffects() |
| Factions | client.factions |
Some | getFactionWarStats() |
| Fittings | client.fittings |
Yes | getFittings(id), createFitting(id, body) |
| Fleets | client.fleets |
Yes | getFleet(id), getFleetMembers(id) |
| Incursions | client.incursions |
No | getIncursions() |
| Industry | client.industry |
Some | getCharacterIndustryJobs(id) |
| Insurance | client.insurance |
No | getInsurancePrices() |
| Killmails | client.killmails |
Some | getKillmail(id, hash) |
| Location | client.location |
Yes | getCharacterLocation(id) |
| Loyalty | client.loyalty |
Yes | getCharacterLoyaltyPoints(id) |
client.mail |
Yes | getCharacterMail(id) |
|
| Market | client.market |
Some | getMarketPrices(), getMarketOrders(regionId) |
| PI | client.pi |
Yes | getCharacterPlanets(id) |
| Route | client.route |
No | getRoute(origin, destination) |
| Search | client.search |
Some | search(characterId, query) |
| Skills | client.skills |
Yes | getCharacterSkills(id) |
| Sovereignty | client.sovereignty |
No | getSovereigntyMap() |
| Status | client.status |
No | getStatus() |
| UI | client.ui |
Yes | setWaypoint(id) |
| Universe | client.universe |
Some | getSystemById(id), getTypeById(id) |
| Wallet | client.wallet |
Yes | getCharacterWallet(id) |
| Wars | client.wars |
No | getWars(), getWarById(id) |
| Freelance Jobs | client.freelanceJobs |
Some | getFreelanceJobs(), getFreelanceJobById(id) |
| Meta | client.meta |
No | getOpenApiJson(), getOpenApiYaml() |
ETag caching is enabled by default. The client automatically:
- Stores ETag and response data on GET requests
- Sends
If-None-Matchon subsequent requests - Returns cached data on
304 Not Modified - Parses
Cache-Control: max-agefrom ESI for per-endpoint TTL - Serves stale cached data when ESI returns 5xx errors
- Invalidates related GET caches when POST/PUT/DELETE requests are made
// Cache stats
const stats = client.getCacheStats();
console.log(`${stats.totalEntries}/${stats.maxEntries} entries cached`);
// Manual cache operations
client.clearCache();
client.updateCacheConfig({ maxEntries: 2000 });
// Disable caching entirely
const uncachedClient = new EsiClient({ enableETagCache: false });Newer ESI routes (Freelance Jobs, and future routes) use cursor-based pagination with opaque before/after tokens in the response body. See the ESI blog post for background.
import { EsiClient, fetchAllCursorPages } from '@lgriffin/esi.ts';
const client = new EsiClient();
// Fetch first page — returns { cursor: { before, after }, freelance_jobs: [...] }
const page = await client.freelanceJobs.getFreelanceJobs();
console.log(page.freelance_jobs); // job records
console.log(page.cursor.after); // opaque token for next page
// Fetch next page using the cursor
const nextPage = await client.freelanceJobs.getFreelanceJobs(
undefined,
page.cursor.after,
);
// Auto-fetch all pages in one call
const allJobs = await fetchAllCursorPages(
(before, after) => client.freelanceJobs.getFreelanceJobs(before, after),
(response) => response.freelance_jobs,
(response) => response.cursor,
);
// Authenticated endpoints — character/corporation freelance jobs
const authedClient = new EsiClient({ accessToken: 'your-token' });
const myJobs =
await authedClient.freelanceJobs.getCharacterFreelanceJobs(characterId);
const corpJobs =
await authedClient.freelanceJobs.getCorporationFreelanceJobs(corporationId);Polling for changes — cursor tokens persist across sessions, so you can save the last after token and poll later to get only records that changed:
// After initial scan, save the final cursor
let savedCursor = lastPage.cursor.after;
// Later: check for updates (hours, days, or weeks later)
const updates = await client.freelanceJobs.getFreelanceJobs(
undefined,
savedCursor,
);
if (updates.freelance_jobs.length > 0) {
// Process changed records — duplicates are expected for modified records
savedCursor = updates.cursor.after;
}Key points:
- Cursor tokens are opaque strings — never parse or validate them
- An empty result array signals the end of the dataset (not a short page)
- Duplicates across pages are expected when records are modified between requests
- Existing offset-based routes (
getMarketOrders, etc.) are unchanged
API errors throw EsiError with statusCode, message, and url properties:
import { EsiError } from '@lgriffin/esi.ts';
try {
const alliance = await client.alliance.getAllianceById(99999999);
console.log('Alliance:', alliance.name);
} catch (err) {
if (err instanceof EsiError) {
console.log(`ESI error ${err.statusCode}: ${err.message}`);
// e.g. "ESI error 404: Resource not found"
} else {
console.error('Network or parse error:', err);
}
}- 204 No Content — returns
undefined(valid for DELETE/POST actions) - 304 Not Modified — handled internally, returns cached data
- 4xx/5xx — throws
EsiError - 5xx with cache — returns stale cached data instead of throwing
If you only need a subset of APIs, use CustomEsiClient or EsiClientBuilder to load only what you need:
import { EsiClientBuilder } from '@lgriffin/esi.ts';
const client = new EsiClientBuilder()
.addClients(['market', 'universe', 'characters'])
.withClientId('my-trading-bot')
.withAccessToken('your-token')
.build();
const prices = await client.market?.getMarketPrices();
const system = await client.universe?.getSystemById(30000142);Or create standalone single-API clients:
import { EsiApiFactory } from '@lgriffin/esi.ts';
const marketClient = EsiApiFactory.createMarketClient({
clientId: 'price-checker',
});
const prices = await marketClient.getMarketPrices();Runnable examples are in the examples/ directory.
npm run example:status # Server status — quickest smoke test
npm run example:character # Character public info, portrait, corporation
npm run example:universe # Solar system, constellation, region, station
npm run example:market # Average prices + Tritanium price history
npm run example:alliance # Alliance info + member corporations
npm run example:route # Jita-to-Amarr route with system names
npm run example:wars # Recent wars with aggressor/defender details
npm run example:sovereignty # Nullsec sovereignty map + active campaigns
npm run example:industry # Industry facilities, cost indices, insurance
npm run example:incursions # Active incursions + faction warfare stats
npm run example:dogma # Item type details + dogma attributes
npm run example:contracts # Public region contracts + auction bids/items
npm run example:rate-limiting # Rate limiter & pagination demonstration
npm run example:cursor-pagination # Freelance Jobs with cursor pagination
npm run example:token-refresh # Automatic token refresh on 401These examples require an EVE SSO token with the listed scopes. Set ESI_ACCESS_TOKEN in your environment or .env file.
npm run example # Full character profile assembly
npm run example:wallet # Wallet balance, journal, transactions (esi-wallet.read_character_wallet.v1)
npm run example:skills # Trained skills, queue, attributes (esi-skills.read_skills.v1, esi-skills.read_skillqueue.v1)
npm run example:assets # Asset inventory with bulk name lookup (esi-assets.read_assets.v1)
npm run example:killmails # Recent killmails + full details (esi-killmails.read_killmails.v1)
npm run example:fleet # Fleet info, members, wing/squad structure (esi-fleets.read_fleet.v1)
npm run example:mail # Inbox headers, labels, mailing lists (esi-mail.read_mail.v1)
npm run example:location # Current system, online status, ship (esi-location.read_location.v1)
npm run example:fittings # Saved fittings + clone state + implants (esi-fittings.read_fittings.v1, esi-clones.read_clones.v1)
npm run example:contacts # Contact list with standings + labels (esi-characters.read_contacts.v1)const [character, portrait, corp] = await Promise.all([
client.characters.getCharacterPublicInfo(characterId),
client.characters.getCharacterPortrait(characterId),
client.corporations.getCorporationInfo(corporationId),
]);
console.log(`${character.name} [${corp.ticker}]`);const [orders, history] = await Promise.all([
client.market.getMarketOrders(regionId),
client.market.getMarketHistory(regionId, typeId),
]);
const buyOrders = orders.filter((o) => o.is_buy_order);
const sellOrders = orders.filter((o) => !o.is_buy_order);
console.log(`Best buy: ${Math.max(...buyOrders.map((o) => o.price))}`);
console.log(`Best sell: ${Math.min(...sellOrders.map((o) => o.price))}`);Always call shutdown() when you're done to clean up cache timers:
const client = new EsiClient();
try {
const status = await client.status.getStatus();
console.log(status.server_version);
} finally {
await client.shutdown();
}- Node.js 18+
- npm
The project uses a comprehensive suite of static analysis and code quality tools:
| Tool | Purpose | Command |
|---|---|---|
| ESLint | Linting with TypeScript, security, and code smell rules | npm run lint |
| Prettier | Code formatting | npm run format:check |
| knip | Dead code and unused export detection | npm run knip |
| eslint-plugin-security | Security anti-pattern detection | Integrated into npm run lint |
| eslint-plugin-sonarjs | Cognitive complexity and code smell detection | Integrated into npm run lint |
| husky | Git pre-commit hooks | Automatic on commit |
| lint-staged | Run linters on staged files only | Automatic on commit |
# Development
npm run build # Compile TypeScript
npm run lint # Run ESLint
npm run lint:fix # Run ESLint with auto-fix
npm run format # Format code with Prettier
npm run format:check # Check formatting without modifying
# Testing
npm test # Unit tests
npm run test:all # Unit + improved + BDD tests
npm run coverage # Tests with coverage report (thresholds enforced)
npm run bdd # BDD scenario tests
# Static Analysis
npm run knip # Detect dead code and unused exports
npm run validate:esi # Validate endpoints against live ESI swagger spec
npm run validate # Run all checks: lint, format, build, coverage, knip
# Documentation
npm run docs # Generate TypeDoc API documentation
npm run docs:serve # Serve docs locally on port 8080To verify that the codebase endpoint definitions match the live ESI swagger spec:
npm run validate:esiThis fetches https://esi.evetech.net/latest/swagger.json and reports:
- Endpoints in the codebase that are no longer in the ESI spec
- Endpoints in the ESI spec that the codebase doesn't cover
- HTTP method mismatches between codebase and spec
The project uses husky with lint-staged to run ESLint and Prettier on staged files before each commit. This is set up automatically when you run npm install.
Every pull request runs the full validation suite:
- ESLint (with security and sonarjs plugins)
- Prettier formatting check
- TypeScript compilation
- Unit tests across Node.js 18, 20, and 22
- BDD scenario tests
- Coverage threshold enforcement (branches: 50%, functions: 50%, lines: 65%, statements: 65%)
- Dead code detection via knip
- npm security audit
See .github/workflows/README.md for full workflow details.
npm test # Unit + integration tests (73 suites, 577 tests)
npm run coverage # Tests with coverage report (thresholds enforced)
npm run bdd # BDD scenario tests onlyTo verify against the live ESI API:
npm run example:status # Confirms ESI connectivity and server status- Fork the repository
- Create a feature branch
- Write tests for your changes
- Run
npm run validateto check everything passes - Open a Pull Request
GPL-3.0-or-later - see the LICENSE file for details.
o7