# WalkScape Public API

A read-only API for community tools that track WalkScape characters, the
leaderboards, where players are, and the weekly job boards.

- **Audience:** authors of third-party tools, bots and websites.
- **Version:** v1. Fields may be *added* to v1 responses; nothing is renamed,
  removed or changes meaning without a new version.
- **Base URL:** `/public/v1` on the server hosting this page. All paths below
  are relative to that host.
- **This page:** always at `/public/v1`. The markdown source is at
  [`/docs/public_api_v1.md`](/docs/public_api_v1.md).

> If your tool currently calls `/portal/shared/characters/{id}`, please move to
> this API. The portal endpoint is shaped by what the portal needs and may change
> without notice; this one will not.

---

## Authentication and limits

None required. Requests are anonymous. Please do not poll faster than the
`Cache-Control` header allows (see [Caching](#caching)); rate limits per IP
address will be introduced, and tools that respect the cache headers will not
notice them.

## Endpoints

### `GET /public/v1/characters/{id}`

Returns one [character](#the-character-object).

- `404` with error type `character_does_not_exist` when there is no such
  character.

### `GET /public/v1/characters?ids={id},{id},...`

Returns up to **100** characters in one request. Ids can be comma separated or
given as repeated parameters (`?ids=a&ids=b`); duplicates are ignored. If you
track many characters, use this instead of one request per character.

```json
{
  "characters": [ { "id": "...", "...": "..." } ],
  "not_found": ["id-that-does-not-exist"]
}
```

`characters` follows the order the ids were requested in, leaving out the ones
listed in `not_found`.

- `400` with error type `missing_query_params` when no ids are given.
- `400` with error type `cap_reached` when more than 100 distinct ids are given.

### `GET /public/v1/characters/directory?after={cursor}`

Lists which characters exist, so you can find ids without scraping the
leaderboards. Returns pages of 1000 in the order characters were created:

```json
{
  "characters": [ { "id": "...", "character_name": "..." } ],
  "next_cursor": "12815",
  "has_more": true
}
```

- Leave out `after` to start from the beginning. Pass the previous page's
  `next_cursor` to continue, until `has_more` is `false`.
- **Keep the last `next_cursor`.** Calling again with it later returns only the
  characters created since, so you never need to walk the whole directory twice.
- Treat the cursor as opaque. Its format may change.
- Only id and name are returned. Fetch the data through the batch endpoint above.
- Characters whose players have hidden them from the leaderboards are not
  listed. Deleted characters silently disappear; the batch endpoint reports them
  in `not_found`.
- `400` with error type `bad_search_string` for a malformed cursor.

### The character object

```json
{
  "id": "walker-4fdb40cb-9cc2-4c7f-b1f8-6eb95e91f950",
  "character_name": "Walker",
  "created_at": "2024-01-07T03:42:37.124597Z",
  "updated_at": "2026-08-02T19:43:55.887438Z",
  "total_steps": 1234567,
  "location_uid": "b1f0e251-ad0c-4be8-bb9b-7e5449dfc221",
  "statistics": { "agility": 123456, "...": "..." },
  "ironfoot": false,
  "user": { "username": "walker", "avatar": null }
}
```

| Field            | Type                  | Notes |
| ---------------- | --------------------- | ----- |
| `id`             | string                | Stable character id. |
| `character_name` | string                | |
| `created_at`     | ISO 8601 UTC string   | |
| `updated_at`     | ISO 8601 UTC string   | Last time the character was saved. |
| `total_steps`    | integer or `null`     | `null` when the player has chosen to hide their steps. |
| `location_uid`   | string or `null`      | The character's current location. |
| `statistics`     | object                | Skill experience and other statistics, keyed by name. |
| `ironfoot`       | boolean               | Whether this is an Ironfoot character. |
| `user`           | object or `null`      | The owning account's `username` and `avatar`. `null` unless the player's profile visibility is public. |

Every field is always present; optional values are `null` rather than missing.

### `GET /public/v1/jobs/weeks`

Lists the job board weeks there are jobs for, oldest first, ending with the
running week. A week runs from Monday 00:00 UTC to the next Monday; week `0`
began on 2024-01-01.

```json
{
  "current_week_index": 147,
  "weeks": [
    { "week_index": 146, "starts_at": "2026-10-19T00:00:00.000Z", "ends_at": "2026-10-26T00:00:00.000Z" },
    { "week_index": 147, "starts_at": "2026-10-26T00:00:00.000Z", "ends_at": "2026-11-02T00:00:00.000Z" }
  ]
}
```

Every player sees the same jobs. A week is never available before it starts.

### `GET /public/v1/jobs/weeks/{weekIndex}`

Returns every board's jobs for one week. `{weekIndex}` is a week number from
the list above, or `current` for the running week.

```json
{
  "week_index": 147,
  "starts_at": "2026-10-26T00:00:00.000Z",
  "ends_at": "2026-11-02T00:00:00.000Z",
  "jobs": [ { "id": 552, "board_id": "...", "...": "..." } ]
}
```

`jobs` are [job objects](#the-job-object), ordered by `board_id` and then
`slot`. On rare occasions a running week's board is rolled again: its jobs are
then replaced by new ones with new ids, and the old ones disappear from this
list and from the stats.

- `404` with error type `does_not_exist` for a week that has not started, has
  no jobs, or is not a week number.

### `GET /public/v1/jobs/weeks/{weekIndex}/stats`

How many characters took part, for one week (`current` works here too). Counts
only; the API never lists who.

```json
{
  "week_index": 147,
  "generated_at": "2026-10-29T12:05:00.000Z",
  "jobs": [
    { "job_id": 552, "accepted_characters": 1834, "completed_characters": 1210 }
  ],
  "boards": [
    {
      "board_id": "...",
      "hand_ins": 5120,
      "characters": 2048,
      "bonus_claims": [ { "milestone": 5, "characters": 312 } ]
    }
  ]
}
```

| Field | Notes |
| ----- | ----- |
| `jobs` | One entry per job **of this week** that anyone accepted, among the jobs the week lists. Accepted jobs can be handed in until their `expires_at`, weeks after their board closed, so these counts keep growing after the week ends. A job never accepted is left out. |
| `accepted_characters` | Characters who accepted the job, including those who accepted and handed it in at once. |
| `completed_characters` | Characters who handed it in. |
| `boards` | What happened on each board **during this week**: `hand_ins` counts every job handed in there, whichever week it was from, and `characters` the characters who handed in at least one. |
| `bonus_claims` | Characters who reached each weekly bonus milestone, by the number of jobs it needs. |
| `generated_at` | When the counts were taken. |

- `404` with error type `does_not_exist`, as for the week itself.

### The job object

```json
{
  "id": 552,
  "board_id": "jobboard-kallaheim-...",
  "slot": 0,
  "difficulty_id": "jobdifficulty-hard",
  "special_id": null,
  "bonus": false,
  "required_level": 2,
  "expires_at": "2026-11-30T00:00:00.000Z",
  "goals": [
    {
      "item": { "id": "item-birch_logs-...", "quality": "common", "tag": "none" },
      "quantity": 214,
      "alternatives": []
    }
  ],
  "rewards": [
    { "kind": "faction_reputation", "item": null, "target_id": "faction-jarvonia-...", "amount": 3 },
    { "kind": "item", "item": { "id": "item-jarvonia_chest-...", "quality": "common", "tag": "none" }, "target_id": null, "amount": 1 }
  ]
}
```

| Field            | Type                | Notes |
| ---------------- | ------------------- | ----- |
| `id`             | integer             | Stable job id, unique across all weeks. |
| `board_id`       | string              | The job board, as in the game data. |
| `slot`           | integer             | Position on the board. |
| `difficulty_id`  | string              | |
| `special_id`     | string or `null`    | Set for a hand-made job, `null` for a generated one. |
| `bonus`          | boolean             | One of its difficulty's bonus jobs, which ask and pay more. |
| `required_level` | integer             | Level in the board's faction needed to accept it. |
| `expires_at`     | ISO 8601 UTC string | A player who accepted the job can hand it in until then. |
| `goals`          | array               | Every goal must be met. A goal takes `quantity` of `item`, or the `quantity` of any one of its `alternatives` instead (higher qualities of the same item). |
| `rewards`        | array               | `kind` is `item`, `coins`, `faction_reputation` or `skill_experience`. `item` is set for `item` rewards, `target_id` (a faction or skill id) for the last two. |

Items are given as their game data `id`, a `quality` (`common`, `uncommon`,
`rare`, `epic`, `legendary` or `ethereal`) and a `tag` (`none`, `fine` or
`rare_egg`). Every field is always present.

### `GET /public/v1/leaderboards/{type}?page={n}`

One page of a leaderboard, 100 entries per page, best first. `{type}` is one of
`total_level`, `total_xp`, `total_steps`, `achievement_points`, `agility`,
`carpentry`, `cooking`, `crafting`, `fishing`, `foraging`, `hunting`, `mining`,
`smithing`, `tailoring`, `trinketry` or `woodcutting`. `page` counts from 1 and
defaults to 1; every page of a board can be fetched.

```json
{
  "type": "total_level",
  "page": 1,
  "page_size": 100,
  "has_more": true,
  "entries": [
    {
      "rank": 1,
      "position": 1,
      "character_id": "walker-4fdb40cb-9cc2-4c7f-b1f8-6eb95e91f950",
      "character_name": "Walker",
      "title_id": null,
      "ironfoot": false,
      "value": 1234,
      "total_xp": 98765432
    }
  ]
}
```

| Field            | Type              | Notes |
| ---------------- | ----------------- | ----- |
| `rank`           | integer           | Characters with the same score share a rank, and the next rank skips accordingly (1, 2, 2, 4). On `total_level`, `total_xp` breaks ties. |
| `position`       | integer           | The place in the list, counting from 1. |
| `character_id`   | string or `null`  | `null` for a player who is anonymous on the leaderboards. |
| `character_name` | string            | `Anonymous Walker` for an anonymous player. |
| `title_id`       | string or `null`  | The character's title, as in the game data. `null` without one, and for an anonymous player. |
| `ironfoot`       | boolean           | Whether this is an Ironfoot character. |
| `value`          | integer or `null` | The score this board ranks by. |
| `total_xp`       | integer           | |

- Keep fetching pages while `has_more` is `true`. A board that ends exactly on
  a full page answers the next page with no entries and `has_more` `false`.
- Characters whose players have hidden them from the leaderboards are left out,
  and so are those who hide their steps, from `total_steps` only. Anonymous
  players are ranked but not named.
- To find character ids, use the [directory](#get-publicv1charactersdirectoryaftercursor),
  not the leaderboards.
- `404` with error type `does_not_exist` for an unknown `{type}`.
- `400` with error type `bad_search_string` when `page` is not a whole number
  from 1 to 1000000.

### `GET /public/v1/census`

How many characters are at each location, counting the characters whose game
was saved in the last 30 days. The window is fixed and counted from the start
of the current hour.

```json
{
  "total": 10779,
  "locations": [
    { "location_uid": "b1f0e251-ad0c-4be8-bb9b-7e5449dfc221", "name": "Kallaheim", "count": 312 }
  ]
}
```

`locations` is ordered by `count`, highest first. `name` is the location's
English name.

### `GET /public/v1/census/activities`

How many of those characters are doing each activity, and where. Characters
that are not doing anything are not counted.

```json
{
  "total": 6259,
  "activities": [
    {
      "activity_id": "...",
      "name": "Birch Logs",
      "total": 120,
      "locations": [
        { "location_uid": "...", "name": "Kallaheim", "count": 80 }
      ]
    }
  ]
}
```

Names are in English. The census never lists characters, only counts.

## Caching

Character data is refreshed at most once a minute. Every response carries:

- `Cache-Control: public, max-age=60` (`max-age=300` for the directory, the
  leaderboards, the job week list, the running week's jobs and all job stats;
  `max-age=900` for the census; `max-age=3600` for the jobs of an ended week).
  Do not ask again for the same data within that time.
- `ETag` — send it back as `If-None-Match` on your next request. If nothing
  changed you get an empty `304 Not Modified`, which is cheaper for both sides.

Because of the server-side cache, a change to a character can take up to about
two minutes to appear here. The leaderboards are rebuilt every two minutes and
cached for five, so they can be up to about seven minutes behind the game, and
the census up to about fifteen.

## Errors

Errors use the standard WalkScape error body:

```json
{
  "error": {
    "type": "character_does_not_exist",
    "code": "1002",
    "error_message": "Character does not exist.",
    "hint": "The character you tried to access does not exist.",
    "http_code": 404
  }
}
```

Only `GET` is accepted; other methods get `400` with error type
`wrong_http_method`.
