API for the Iron Forged Old School RuneScape clan.
| Method | Path | Required perm |
|---|---|---|
| GET | /health |
none (public) |
| GET | /members |
members:list |
| GET | /members/{member_id} |
members:read |
| GET | /members/{member_id}/ingots |
ingots:read |
| GET | /members/{member_id}/ingots/transactions |
ingots:read:transactions |
| GET | /score/{rsn} |
scores:read |
| GET | /score/{rsn}/breakdown |
scores:read |
| GET | /score/{rsn}/history |
scores:read:history |
Every request to a private endpoint needs a Bearer token in the
Authorization header:
curl -H "Authorization: Bearer <token>" http://localhost:8080/membersPermissions are resource:action strings, stored as a JSON array on each
consumer.
| Perm | Grants access to |
|---|---|
members:list |
GET /members |
members:read |
GET /members/{member_id} |
ingots:read |
GET /members/{member_id}/ingots |
ingots:read:transactions |
GET /members/{member_id}/ingots/transactions |
scores:read |
GET /score/{rsn} |
scores:read |
GET /score/{rsn}/breakdown |
scores:read:history |
GET /score/{rsn}/history |
Success:
{
"data": {},
"meta": {
"request_id": "8f3a2c1b-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"timestamp": "2026-07-12T14:23:11.123456+00:00"
}
}Error:
{
"error": {
"code": "not_found",
"message": "No member with id=123456789012345678"
},
"meta": {
"request_id": "8f3a2c1b-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"timestamp": "2026-07-12T14:23:11.123456+00:00"
}
}code is a stable string. message is human-readable and can change.
| Status | code |
When |
|---|---|---|
| 400 | bad_request |
Bad path or query params. |
| 401 | unauthorized |
Missing, malformed, or revoked token. |
| 403 | forbidden |
Token is valid but missing the required perm. |
| 404 | not_found |
Member, player, or hiscores record does not exist. |
| 405 | method_not_allowed |
Method not supported for the given path. |
| 422 | validation_error |
Request validation failed. |
| 429 | rate_limited |
Per-consumer per-minute limit exceeded. |
| 500 | internal_error |
Unhandled server-side exception. |
Every response also carries an X-Request-ID header with the same value as
meta.request_id. If having issues, share this ID to help with debugging.
Per-consumer per-route limit, 30 requests per minute by default. 429 responses
use the standard response envelope and include a Retry-After header.
Pings the database.
Required perm: none
Response 200:
{
"data": {
"status": "ok",
"db": "ok",
"version": "1.0.0",
"environment": "prod"
},
"meta": { "request_id": "...", "timestamp": "..." }
}Returns 503 with "status": "degraded" and "db": "error" when the database is
unreachable.
Paginated member list. Default filter returns active members only.
Required perm: members:list
| Name | Type | Default | Constraints |
|---|---|---|---|
limit |
int | 100 |
1-500 |
offset |
int | 0 |
>= 0 |
role |
string | none | A ROLE enum value, e.g. Member, Staff, Owner |
rank |
string | none | A RANK enum value, e.g. Iron, Dragon, Myth |
filter |
string | active |
active, booster, prospect, blacklisted, banned |
Response 200:
{
"data": {
"members": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"discord_id": 123456789012345678,
"nickname": "Zezima",
"role": "Member",
"rank": "Dragon",
"joined_date": "2024-01-15T00:00:00+00:00",
"is_booster": false,
"is_prospect": false,
"is_blacklisted": false,
"is_banned": false
}
],
"total": 142,
"limit": 100,
"offset": 0
},
"meta": { "request_id": "...", "timestamp": "..." }
}Single member by Discord id or internal UUID. Numeric path values match against
discord_id; non-numeric values match against the internal UUID.
Required perm: members:read
Response 200:
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"discord_id": 123456789012345678,
"nickname": "Zezima",
"role": "Member",
"rank": "Dragon",
"joined_date": "2024-01-15T00:00:00+00:00",
"is_booster": false,
"is_prospect": false,
"is_blacklisted": false,
"is_banned": false
},
"meta": { "request_id": "...", "timestamp": "..." }
}Current ingot balance. member_id is a Discord id or internal UUID.
Required perm: ingots:read
Response 200:
{
"data": { "nickname": "Zezima", "ingots": 4200 },
"meta": { "request_id": "...", "timestamp": "..." }
}Recent add/remove ingot transactions, newest first. member_id is a Discord id
or UUID.
Required perm: ingots:read:transactions
| Name | Type | Default | Constraints |
|---|---|---|---|
days |
int | none | 1-365. Filter to transactions within the last N days. |
limit |
int | 50 |
1-500 |
Response 200:
{
"data": {
"transactions": [
{
"id": 98765,
"change_type": "ADD_INGOTS",
"previous_value": "4000",
"new_value": "4200",
"comment": "Payroll",
"admin": {
"id": "f0e1d2c3-b4a5-9687-6543-210fedcba987",
"discord_id": 111222333444555666,
"nickname": "BossMan"
},
"timestamp": "2026-07-01T06:00:00+00:00"
},
{
"id": 98760,
"change_type": "REMOVE_INGOTS",
"previous_value": "4050",
"new_value": "4000",
"comment": "Bought raffle tickets",
"admin": null,
"timestamp": "2026-06-15T18:42:11+00:00"
}
]
},
"meta": { "request_id": "...", "timestamp": "..." }
}admin is null when the changelog row has no admin_id. This typically means
the change was initiated by the bot (think automated payroll). When populated,
it is a MemberRef (internal UUID, Discord id, nickname).
Compute score and rank for a player. For full breakdown, see
GET /score/{rsn}/breakdown.
Required perm: scores:read
| Name | Type | Description |
|---|---|---|
rsn |
string | RuneScape name. 1-12 characters. 400 outside range. |
| Name | Type | Default | Description |
|---|---|---|---|
bypass_cache |
bool | false |
Skip the score cache and force a fresh hiscores fetch. |
Response 200:
{
"data": {
"player_name": "Zezima",
"total_points": 12345,
"rank": "Rune"
},
"meta": { "request_id": "...", "timestamp": "..." }
}OSRS hiscores breakdown converted to clan points.
Required perm: scores:read
| Name | Type | Description |
|---|---|---|
rsn |
string | RuneScape name. 1-12 characters. 400 outside range. |
| Name | Type | Default | Description |
|---|---|---|---|
bypass_cache |
bool | false |
Skip the score cache and force a fresh hiscores fetch. |
Response 200:
{
"data": {
"player_name": "Zezima",
"rank": "Rune",
"skills": [
{
"name": "Attack",
"display_name": null,
"display_order": 1,
"emoji_key": "Attack",
"level": 99,
"xp": 13034431,
"points": 152
}
],
"clues": [
{
"name": "Clue Scrolls (beginner)",
"display_name": "Beginner",
"display_order": 1,
"emoji_key": "Beginner_Clue",
"kc": 42,
"points": 4
}
],
"raids": [
{
"name": "Chambers of Xeric",
"display_name": null,
"display_order": 1,
"emoji_key": "Chambers_of_Xeric",
"kc": 75,
"points": 75
}
],
"bosses": [
{
"name": "Zulrah",
"display_name": null,
"display_order": 200,
"emoji_key": "Zulrah",
"kc": 1200,
"points": 600
}
],
"total_points": 12345
},
"meta": { "request_id": "...", "timestamp": "..." }
}Historical score snapshots for a registered member, looked up at multiple periods. The player must be a clan member.
Required perm: scores:read:history
rsn is a osrs name (see GET /score/{rsn}).
| Name | Type | Default | Constraints |
|---|---|---|---|
days |
string | "7,30,90" |
Comma-separated day windows. Each value 1-365. 400 if any value is bad. |
For each requested period, the endpoint returns the single nearest snapshot
within ~3 days of the target date. If no qualifying snapshot exists, score is
null.
Response 200:
{
"data": {
"discord_id": 123456789012345678,
"entries": [
{ "period_days": 7, "score": 11800, "snapshot_date": null },
{ "period_days": 30, "score": 10500, "snapshot_date": null },
{ "period_days": 90, "score": 9000, "snapshot_date": null }
]
},
"meta": { "request_id": "...", "timestamp": "..." }
}The api uses uv as its package manager. Install uv on your host:
curl -LsSf https://astral.sh/uv/install.sh | shDependencies are declared in api/pyproject.toml and pinned via
api/uv.lock.
-
Set the API port in
.env:API_PORT=8080. -
Run migrations:
make migrate. -
Create a consumer:
make api-consumer-interactive. The CLI prints a fresh bearer token once. Copy it. -
Hit an endpoint:
curl -H "Authorization: Bearer iron_<token>" http://localhost:8080/members
| Env var | Default | Description |
|---|---|---|
API_HOST |
0.0.0.0 |
Bind address. |
API_PORT |
8080 |
Listen port. |
API_TRUSTED_HOSTS |
127.0.0.1 |
Comma-separated trusted reverse-proxy IPs for X-Forwarded-For parsing. |
API_CORS_ORIGINS |
(empty) | Comma-separated allowed CORS origins. |
API_RATE_LIMIT |
30 |
Per-route per-consumer per-minute. 0 disables. |
A Bruno collection lives in api/bruno/.