API Routes¶
Conventions¶
- Base URL:
https://api.aelghar.com(Workers route) - All routes:
Content-Type: application/json - Auth-protected routes require
Authorization: Bearer <access_token>header - Unauth routes:
/api/auth/* - Character context derived from JWT
cidclaim on all protected routes - Standard error envelope:
{ "error": "snake_case_code", "message": "human readable" } - Standard success envelope:
{ "data": ... }or direct object for single-resource GETs - Pagination: cursor-based via
?cursor=<opaque>and?limit=<n>(default 20, max 100)
Auth Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
No | Create account |
| POST | /api/auth/login |
No | Login, get token pair |
| POST | /api/auth/refresh |
Cookie | Refresh access token |
| POST | /api/auth/logout |
Cookie | Revoke refresh token |
POST /api/auth/register¶
Request:
{
"email": string,
"password": string, // ≥ 12 chars
"display_name": string // 2–30 chars
}
Response 201:
{
"access_token": string,
"character_required": true // signals client to show character creation
}
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Max-Age=604800
Errors: 400 email_invalid | 400 password_too_short | 400 display_name_invalid | 409 email_taken
POST /api/auth/login¶
Request: { "email": string, "password": string }
Response 200:
{
"access_token": string,
"character_id": string | null // null if no character created yet
}
Set-Cookie: refresh_token=...
Errors: 401 invalid_credentials | 429 rate_limited
POST /api/auth/refresh¶
Errors: 401 token_invalid | 401 token_expired
POST /api/auth/logout¶
Character Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/characters |
Yes | List characters on account |
| POST | /api/characters |
Yes | Create character |
| GET | /api/characters/:id |
Yes | Get character profile |
| GET | /api/characters/:id/skills |
Yes | Skill list with bands and XP |
| GET | /api/characters/:id/inventory |
Yes | Inventory contents |
| GET | /api/characters/:id/flags |
Yes | Onboarding and system flags |
| GET | /api/characters/:id/codex |
Yes | Codex entries |
POST /api/characters¶
Request:
{
"name": string, // 2–30 chars, unique per server
"lineage": string, // content schema lineage id
"village_origin": string, // village id
"archetype": string, // archetype id
"background": string // background id
}
Response 201: { "character": CharacterObject }
Errors: 400 invalid_field | 409 name_taken | 409 character_already_exists (one per account at launch)
GET /api/characters/:id¶
Response 200:
{
"id": string,
"name": string,
"lineage": string,
"village_origin": string,
"archetype": string,
"background": string,
"location_id": string,
"location_name": string,
"coin": integer,
"hall_ranks": { "adventurers_hall": "F", "crafters_hall": null },
"active_expedition_count": integer,
"created_at": integer
}
Board Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/board |
Yes | Board at character's current location |
| GET | /api/board/:locationId |
Yes | Board at specific location (preview allowed) |
| POST | /api/board/:itemId/accept |
Yes | Accept a board order |
| GET | /api/board/acceptances |
Yes | Character's active acceptances |
GET /api/board/:locationId¶
Query: ?guild=adventurers_hall|crafters_hall|town_board|caravan_board
?rank=F|E|D|C|B|A|S (filter by rank; returns eligible ranks only by default)
?status=open (default; or: accepted, completed, expired)
?cursor=&limit=
Response 200:
{
"location": { "id": string, "name": string },
"items": [ BoardItemObject ],
"cursor": string | null
}
BoardItemObject:
{
"id": "...",
"guild": "adventurers_hall",
"rank": "E",
"type": "cull_order",
"display_name": "Cull Order — Dragonmoss Ridge",
"summary": "Reduce pack size below nuisance threshold.",
"coin_reward": 80,
"duration_hours": [6, 10],
"min_party_size": 1,
"tags": ["combat", "solo_ok"],
"expires_in_seconds": 43200,
"status": "open"
}
POST /api/board/:itemId/accept¶
Request: {} (body empty; character and expedition data derived server-side)
Response 201:
{
"acceptance_id": string,
"expedition_id": string,
"resolves_at": integer // Unix timestamp
}
Errors: 400 already_accepted | 400 rank_insufficient | 400 not_at_location | 400 order_full | 409 expedition_conflict (active expedition already running for this character)
Idempotency: server checks (board_item_id, character_id) unique constraint before inserting. Duplicate request returns 200 with existing acceptance, not 201.
Expedition Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/expeditions |
Yes | Character's active and recent expeditions |
| GET | /api/expeditions/:id |
Yes | Expedition detail |
| DELETE | /api/expeditions/:id |
Yes | Cancel (if not yet resolved) |
GET /api/expeditions¶
Query: ?status=active|resolved|failed|all (default: active)
?cursor=&limit=
Response 200:
{
"expeditions": [ ExpeditionObject ]
}
ExpeditionObject:
{
"id": "...",
"template_id": "cull_peltorotta_farmland",
"display_name": "Cull Order — Dragonmoss Ridge",
"status": "active",
"location": "Trevalkaan",
"destination": "Dragonmoss Ridge",
"started_at": 1234567890,
"resolves_at": 1234603200,
"participants": [ { "character_id": "...", "name": "...", "role": "combat" } ],
"result": null // populated when resolved
}
GET /api/expeditions/:id¶
Same as above but single record. result populated on resolution:
"result": {
"quality_score": 0.82,
"outcome": "success",
"narrative": "The pack was driven from the ridge. Two animals put down outright.",
"rewards": {
"coin": 80,
"items": [ { "item_id": "peltorotta_pelt", "quantity": 3 } ],
"skill_xp": [ { "skill_id": "combat_melee", "xp": 12 } ]
},
"injuries": []
}
Market Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/market/browse |
Yes | Browse sell orders |
| GET | /api/market/price-index |
Yes | Price index + 7-day history for an item |
| GET | /api/market/buy-orders |
Yes | Active buy orders |
| POST | /api/market/orders/sell |
Yes | Post sell order |
| POST | /api/market/orders/buy |
Yes | Post buy order |
| POST | /api/market/orders/:id/fill |
Yes | Fulfill a buy order |
| POST | /api/market/orders/:id/cancel |
Yes | Cancel own order |
| POST | /api/market/orders/:id/extend |
Yes | Extend order lifetime |
| GET | /api/market/my-orders |
Yes | Own active orders |
| GET | /api/market/auctions |
Yes | Active auction lots |
| POST | /api/market/auctions/:id/bid |
Yes | Place bid |
| GET | /api/market/warehouse |
Yes | Own warehouse holds |
| POST | /api/market/warehouse/:id/collect |
Yes | Collect from hold |
| POST | /api/market/warehouse/:id/list |
Yes | Move hold to sell order |
GET /api/market/browse¶
Query: ?town_id= (required; defaults to character location)
?category=food.preserved
?search=trout (item name search, ≥ 2 chars)
?sort=price_asc|price_desc|qty_desc|recent (default: price_asc)
?cursor=&limit=
Response 200:
{
"town": { "id": string, "name": string },
"shortage_bands": { "food": "strained", "medicine": "stable" },
"orders": [ SellOrderObject ],
"cursor": string | null
}
POST /api/market/orders/sell¶
Request:
{
"town_id": string,
"item_id": string,
"quantity": integer,
"price_per_unit": integer,
"source": "inventory" | "warehouse",
"warehouse_hold_id": string | null // required if source=warehouse
}
Response 201:
{
"order_id": string,
"fee_paid": integer,
"expires_at": integer
}
Errors: 400 insufficient_items | 400 order_limit_reached | 400 not_at_location | 400 price_invalid
GET /api/market/price-index¶
Query: ?town_id=&item_id=
Response 200:
{
"item_id": string,
"current_index": 42.5,
"history": [
{ "recorded_at": integer, "index_value": float }
// 7 entries (one per day)
],
"multipliers": {
"scarcity": 1.3,
"route": 1.0,
"event": 1.0,
"tax": 1.0,
"liquidity": 1.0
}
}
Town Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/towns/:id |
Yes | Town state snapshot |
| GET | /api/towns/:id/board |
Yes | Town board items |
| GET | /api/towns/:id/events |
Yes | Active town events |
| GET | /api/towns/:id/projects |
Yes | Active public works |
GET /api/towns/:id¶
Response reads from KV town_snapshot:{id} first; falls back to D1 if KV miss.
Response 200:
{
"id": string,
"name": string,
"health_state": "stable",
"population": 420,
"reserves": {
"food": { "units": 1200.5, "band": "stable", "days_of_cover": 7.2 },
"water": { "units": 880.0, "band": "stable", "days_of_cover": 8.1 },
"fuel": { "units": 340.0, "band": "strained", "days_of_cover": 3.4 },
"medicine": { "units": 95.0, "band": "stable", "days_of_cover": 9.5 },
"building": { "units": 210.0, "band": "growing", "days_of_cover": 14.0 }
},
"controlling_guild": { "id": string, "name": string, "tag": string } | null,
"last_tick_at": integer
}
Guild Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/guild |
Yes | Own guild (from JWT character) |
| GET | /api/guild/members |
Yes | Roster |
| GET | /api/guild/influence |
Yes | Influence panel per town |
| GET | /api/guild/storage |
Yes | Storage contents |
| POST | /api/guild/storage/deposit |
Yes | Deposit item |
| POST | /api/guild/storage/withdraw |
Yes | Withdraw item |
| GET | /api/guild/treasury |
Officer+ | Treasury ledger |
| POST | /api/guild/treasury/distribute |
Officer+ | Distribute coin to members |
| GET | /api/guild/departure-slots |
Yes | Active departure slots |
| POST | /api/guild/departure-slots |
Officer+ | Post new departure slot |
| POST | /api/guild/departure-slots/:id/join |
Yes | Commit to slot |
| POST | /api/guild/departure-slots/:id/leave |
Yes | Leave slot |
| POST | /api/guilds |
Yes | Found a new guild |
POST /api/guilds¶
Request:
{
"name": string, // 3–30 chars
"tag": string, // 2–5 chars, uppercase
"co_founder_id": string // second character required at founding
}
Response 201: { "guild": GuildObject }
Errors: 400 insufficient_renown | 400 insufficient_coin | 409 name_taken | 409 tag_taken
Admin Routes¶
Protected by X-Admin-Secret: <secret> header (Workers Secret binding) in addition to normal auth. Never expose to browser clients.
| Method | Path | Description |
|---|---|---|
| POST | /api/admin/content/reload |
Hot-reload content YAML into D1 |
| POST | /api/admin/towns/:id/tick |
Force manual settlement tick |
| GET | /api/admin/towns/:id/ledger |
Full daily ledger for a town |
| POST | /api/admin/characters/:id/coin |
Admin coin adjustment |
Messaging Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/messages |
Yes | Send message |
| GET | /api/messages/inbox |
Yes | Inbox (paginated, 20/page, sent_at DESC) |
| GET | /api/messages/sent |
Yes | Sent folder (paginated) |
| GET | /api/messages/:id |
Yes | Full message; marks read_at if recipient |
| POST | /api/messages/:id/read |
Yes | Explicit read mark (idempotent) |
| DELETE | /api/messages/:id |
Yes | Soft-delete |
POST /api/messages¶
Request:
{
"recipient_id": string, // character id
"subject": string, // max 120 chars
"body": string // plain text, max 2000 chars
}
Response 201: { "message_id": string }
Errors: 400 recipient_not_found | 400 subject_too_long | 400 body_too_long | 400 cannot_message_self | 429 message_rate_limited
Rate limit: 30 messages per account per hour enforced in Worker (D1 count query on send path). WAF rule: 10 req/IP/min on this endpoint.
GET /api/messages/inbox¶
Query: ?cursor=&limit=20
Response 200:
{
"unread_count": 3,
"messages": [
{
"id": string,
"sender_id": string,
"sender_name": string,
"subject": string,
"sent_at": integer,
"read_at": integer | null
}
],
"cursor": string | null
}
GET /api/messages/:id¶
Returns full message body. If caller is recipient and read_at is null: sets read_at, decrements inbox_meta:{character_id} KV unread count.
Response 200:
{
"id": string,
"sender_id": string,
"sender_name": string,
"subject": string,
"body": string,
"sent_at": integer,
"read_at": integer | null
}
Leaderboard Routes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/leaderboard |
Yes | Pre-computed leaderboard from KV |
| GET | /api/leaderboard/me |
Yes | Caller's rank (D1 count, 1h KV cache) |
GET /api/leaderboard¶
Query: ?window=weekly|monthly|all_time (required)
?category=expeditions_completed|coin_earned|items_crafted|town_influence|elite_kills|boss_kills
(required)
Response 200:
{
"window": "weekly",
"category": "expeditions_completed",
"computed_at": integer | null,
"entries": [
{ "rank": 1, "character_id": string, "display_name": string, "value": integer }
]
}
Reads leaderboard:{window}:{category} from KV. No D1 hit. Returns { "entries": [] } if key not yet populated.
GET /api/leaderboard/me¶
Query: ?window=weekly|monthly|all_time
?category=...
Response 200: { "rank": integer, "value": integer }
D1 count query to find caller's rank. Result cached in leaderboard_me:{character_id}:{window}:{category} KV for 1 hour.
Character Search¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/characters/search |
Yes | Search characters by display name |
GET /api/characters/search¶
Query: ?q=string (min 2 chars, max 30)
Response 200:
{
"results": [
{ "id": string, "display_name": string, "location_name": string }
]
}
Returns max 10 results. D1 query: WHERE display_name LIKE ? LIMIT 10. Not cached — low-frequency call from message compose form only.
Error Codes Reference¶
| HTTP | Code | Meaning |
|---|---|---|
| 400 | validation_error |
Generic field validation fail |
| 400 | not_at_location |
Character not present at required location |
| 400 | rank_insufficient |
Guild rank too low for this order |
| 400 | order_limit_reached |
Character at max simultaneous orders |
| 400 | insufficient_items |
Inventory or warehouse lacks required items |
| 401 | unauthorized |
Missing or invalid access token |
| 401 | token_expired |
Access token expired; client should refresh |
| 403 | forbidden |
Token valid but insufficient permissions |
| 404 | not_found |
Resource not found |
| 409 | conflict |
State conflict (duplicate, unique constraint) |
| 429 | rate_limited |
Too many requests |
| 503 | town_degraded |
Town DO in degraded state; data unavailable |
Degraded State Handling¶
When a Town DO or Market DO is in degraded state, the Worker returns 503 town_degraded. The client displays a non-blocking amber banner: "Town data temporarily unavailable. Last known state shown." and continues serving from the last KV snapshot. Retries with exponential backoff (1s, 2s, 4s, max 3 attempts) before surfacing the error.