Skip to content

Repository files navigation

palworld-server-api

npm CI node license

Typed client and CLI for the Palworld dedicated server REST API. Zero runtime dependencies, ESM and CommonJS, types generated from an OpenAPI spec that ships with the package.

Do not expose this API to the Internet. The official docs are explicit: these endpoints are not designed for direct Internet exposure, and publishing them can let anyone manipulate your server. Keep the REST port on your LAN or behind a VPN. Everything in this package assumes you have done that.

Install

npm install palworld-server-api

Node 20.19 or newer.

Quick start

import { PalworldClient } from 'palworld-server-api';

const client = new PalworldClient({
  host: 'pal.lan',
  password: process.env.PALWORLD_PASSWORD!,
});

const { servername } = await client.getInfo();
const players = await client.getPlayers();

console.log(`${servername}: ${players.length} online`);
await client.announce('Server restarting in 5 minutes');

CommonJS works too:

const { PalworldClient } = require('palworld-server-api');

Server prerequisites

In PalWorldSettings.ini:

RESTAPIEnabled=True
RESTAPIPort=8212
AdminPassword=<your-password>

AdminPassword is the password this client authenticates with; the username is always admin. getGameData() additionally needs the server launched with -enable-gamedata-api.

CLI

export PALWORLD_HOST=pal.lan
export PALWORLD_PASSWORD=hunter2

npx palworld players
npx palworld announce "Raid night in 10"
npx palworld kick --name Griefer --message "See you tomorrow"
$ palworld players
NAME      LEVEL   PING  BUILDINGS  LOCATION           USERID
PalUser       1  3.1ms        119  (123.5, 67.9)      steam_00000000000000000
Otherguy     22   41ms          3  (-820, 1100.3)     steam_00000000000000001

2 players online
$ palworld metrics
FPS           57
Frame time    16.77 ms
Players       10 / 32
Base camps    32
Uptime        1h 0m 0s
In-game days  1

Commands

Command Purpose
info Server version, name and world GUID
players [--sort name|level|ping|buildings] List connected players
settings [--key <name>] Show settings, or one value raw
metrics FPS, players, uptime, in-game days
game-data [--type <T>] [--unit <U>] World actor snapshot
announce <message> Broadcast a message
kick <userid> | --name <n> [--message <m>] Kick a player
ban <userid> | --name <n> [--message <m>] Ban a player
unban <userid> Lift a ban
save Save the world
shutdown [seconds] [--message <m>] [--wait] Shut down after a countdown
stop --yes Force stop now, without saving
watch [--interval <s>] [--initial] Stream join and leave events

kick and ban take either a positional userid or --name. Passing both, or neither, is a usage error — there is deliberately no guessing at which one you meant, because guessing wrong kicks the wrong person.

stop is the one irreversible command, so it requires --yes or an interactive confirmation. Run non-interactively without --yes and it refuses rather than hanging.

Connection flags

Every flag has an environment variable, and the flag wins.

Flag Environment Default
--host <h> PALWORLD_HOST localhost
--port <n> PALWORLD_PORT 8212
--protocol <http|https> PALWORLD_PROTOCOL http
--base-url <url> PALWORLD_BASE_URL
--password <p> PALWORLD_PASSWORD required
--password-file <path> PALWORLD_PASSWORD_FILE
--user <u> PALWORLD_USERNAME admin
--timeout <ms> PALWORLD_TIMEOUT 10000

Prefer PALWORLD_PASSWORD or --password-file. --password is visible in ps output and shell history, and the CLI says so on a TTY.

Output flags: --json for machine-readable output (watch --json emits NDJSON, one event per line, so it pipes into jq), --no-color (or NO_COLOR), and -q / --quiet.

Exit codes

Code Meaning
0 Success
1 Unexpected error
2 Usage error: unknown command, bad flags, missing password
3 Authentication failed
4 Could not connect
5 Timed out
6 Other HTTP error
7 Player not found
130 Interrupted

API

Options

new PalworldClient({
  host: 'localhost',      // ignored when baseUrl is set
  port: 8212,
  protocol: 'http',
  baseUrl: undefined,     // origin + optional prefix; apiPath is still appended
  apiPath: '/v1/api',
  username: 'admin',
  password: 'required',
  timeout: 10_000,        // per attempt; 0 disables
  retry: { retries: 2 },  // or false
  fetch: undefined,       // inject your own
  headers: {},
  signal: undefined,      // client-wide kill switch
  userAgent: undefined,
});

baseUrl is the origin, and apiPath is always appended to it — there is no sniffing for whether you already included /v1/api. Behind a proxy that strips the version segment, set apiPath: ''.

Endpoints

client.getInfo();       // ServerInfo
client.getPlayers();    // Player[]  — the { players: [...] } envelope is unwrapped
client.getSettings();   // ServerSettings
client.getMetrics();    // ServerMetrics
client.getGameData();   // GameDataSnapshot

client.announce(message);
client.kick(userId, { message });
client.ban(userId, { message });
client.unban(userId);
client.save();
client.shutdown(seconds, { message });
client.stop();

Every method takes a trailing options object accepting signal, timeout and retry. For anything not modelled here, client.request(method, path, { body, accept }) reuses the same base URL, auth and retry policy.

Helpers

await client.findPlayerByName('PalUser');            // Player | undefined
await client.resolveUserId('PalUser');               // throws if not connected
await client.kickByName('Griefer', { message: '…' });
await client.banByName('Griefer');
await client.isOnline();                             // a 401 still counts as online
await client.waitForShutdown({ timeoutMs: 60_000 });
await client.waitForOnline();
await client.saveAndStop({ announce: 'Going down' });

watchPlayers() polls the player list and yields join and leave events:

const ac = new AbortController();

for await (const event of client.watchPlayers({ intervalMs: 5000, signal: ac.signal })) {
  console.log(`${event.player.name} ${event.type === 'join' ? 'joined' : 'left'}`);
}

It ends cleanly when the signal aborts. By default it does not emit for players already online at the first poll; pass emitInitial: true if you want the opening roster.

Mutations return a CommandResult

The official docs describe the mutating endpoints' responses only in prose, never as a schema. Rather than assume the body, those methods return:

const result = await client.save();
// { status: 200, body: 'OK', ok: true }

ok is true when the server acknowledged the way the docs describe — the body is OK in any case, or empty. It does not mean "HTTP 2xx"; that is implied by the call not throwing. A localised or newer server replying with something else gives you ok: false with the raw text in body, so an unexpected answer is visible without being fatal.

Errors

Everything thrown extends PalworldError and carries a code, plus the failing request and the number of attempts.

Class code Thrown when
PalworldConfigError CONFIG Bad client options
PalworldHttpError HTTP Any other non-2xx response
PalworldAuthError AUTH 401 or 403
PalworldBadRequestError BAD_REQUEST 400
PalworldTimeoutError TIMEOUT The per-attempt timeout elapsed
PalworldNetworkError NETWORK DNS, connection refused, TLS, socket reset
PalworldAbortError ABORTED Your AbortSignal fired
PalworldParseError PARSE 2xx with a body that was not valid JSON
PalworldPlayerNotFoundError PLAYER_NOT_FOUND A name lookup matched nobody

PalworldAuthError and PalworldBadRequestError both extend PalworldHttpError, so catching that catches them too.

import { isPalworldError } from 'palworld-server-api';

try {
  await client.getInfo();
} catch (error) {
  if (!isPalworldError(error)) throw error;

  switch (error.code) {
    case 'AUTH':    console.error('Check AdminPassword and RESTAPIEnabled'); break;
    case 'NETWORK': console.error('Server unreachable'); break;
    default:        console.error(error.message);
  }
}

Prefer branching on error.code over instanceof if two copies of this package might end up in one dependency graph — instanceof compares constructor identity and fails across duplicates, the string code does not.

Timeouts and retries

Only GET requests retry by default, twice, with exponential backoff and full jitter.

Endpoint Retries Why
/info, /players, /settings, /metrics, /game-data yes Pure reads
/announce no A duplicate broadcasts to everyone twice
/kick no A retry can hit someone who reconnected in between
/ban, /unban no Effectively idempotent, but held to the same rule for predictability
/save no Harmless but causes a second server hitch
/shutdown, /stop no Repeating these is actively dangerous

Retries are skipped entirely for auth failures, 4xx other than 408/425/429, parse failures, and anything you aborted. Retry-After is honoured, clamped to maxDelay.

Opt in per call when you want it: await client.save({ retry: 3 }). Opt out with retry: false.

The timeout is per attempt, not per call. With defaults a GET can take about 34 seconds across three attempts plus backoff. For a budget across the whole call including backoff, pass signal: AbortSignal.timeout(ms).

Types

Types are generated from palworld-openapi.yaml by openapi-typescript and re-exported under readable names:

import type { Player, ServerInfo, ServerMetrics, GameDataSnapshot } from 'palworld-server-api';

Actor snapshots are a discriminated union with runtime guards:

import { isCharacterActor, isActorActive } from 'palworld-server-api';

const snapshot = await client.getGameData();
const active = snapshot.ActorData.filter(isCharacterActor).filter(isActorActive);

The spec that generated these types is the same one shipped in the package, so the two cannot disagree about what the server returns.

Gotchas

  • userId, not playerId. Kick, ban and unban take the userId from getPlayers() (steam_00000000000000000). playerId is a different value and will not work.
  • IsActive is a string. CharacterActor.IsActive is "true" or "false" on the wire, so if (actor.IsActive) is true even for an inactive actor. Use isActorActive().
  • GameDataSnapshot.Time is not ISO 8601. It is "YYYY-MM-DD HH:MM:SS" in the server's local time, so new Date(snapshot.Time) will be parsed as local time on your machine, not the server's.
  • stop() does not save. Call save() first, or use saveAndStop().
  • ServerSettings keys vary by server version, so every key is typed optional. That is deliberate.
  • getGameData() needs -enable-gamedata-api. Without it the server answers 404, which arrives as a PalworldHttpError whose message names the missing flag. The official docs do not mention this status; it was measured against a server running 1.0.2.

Versioning

This package follows semver for its own API. The version says nothing about which Palworld server release you are running — a version that encoded both would have to pick one to lie about the moment either side shipped a fix.

It is 0.x deliberately. The endpoints have been exercised against a live Palworld server running 1.0.2, but the public API here has not yet had contact with real users, and 0.x leaves room to correct a design decision without a major bump in week one. Expect 1.0.0 once the surface has settled.

Server compatibility is stated separately, and that is the value to trust:

import { SUPPORTED_API_VERSION, SUPPORTED_SERVER_VERSIONS } from 'palworld-server-api';
// 'v0.2.0.0'  and  '>=0.2.4.0 <=1.0.2'

The same pair is on the palworld field in package.json, so tooling can read it without importing anything.

v0.2.0.0 is the info.version of Pocketpair's own OpenAPI document. It has not changed since the REST API was introduced in server 0.2.4.0, which is why one client can cover the whole 0.2.4.01.0.2 range. Note that no endpoint reports it, and getInfo().version returns the server build version, which is a different number again.

OpenAPI spec

An OpenAPI 3.0.3 description of the API ships inside the package, so you can point your own codegen or an HTTP client at it:

const specPath = require.resolve('palworld-server-api/openapi.yaml');

See docs/openapi.md for Postman import instructions, the endpoint table, and notes on where the spec departs from the published docs.

Contributing

Issues and pull requests: https://github.com/OrganismZero/palworld-server-api

Behaviour here has been checked against a dedicated server running 1.0.2, including two things the official docs describe only in prose: mutating endpoints do reply OK, and /game-data answers 404 when the launch flag is absent. Older builds in the supported range have not been exercised, and mutations still hand back a CommandResult rather than assuming the body, so an unfamiliar reply surfaces as ok: false instead of throwing. If your server behaves differently, that report is genuinely useful — please open an issue.

License

MIT. Palworld is a trademark of Pocketpair, Inc. This project is unofficial and is not affiliated with or endorsed by Pocketpair.

About

Typed client and CLI for the Palworld dedicated server REST API. Zero runtime dependencies, ESM + CJS.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages