Skip to main content

MinisVault API Reference

Version: 1.0
Base URL: https://api.minisvault.example/api/v1
Protocol: HTTPS
Format: JSON

MinisVault API stores a collector's acrylic paints and miniatures. Every resource belongs to the authenticated user. This reference covers the two operations in the v1 contract:

OperationMethod and path
List acrylic paintsGET /api/v1/paints
Add a miniaturePOST /api/v1/miniatures

Authentication​

Send a signed JWT in the Authorization header on every request.

Authorization: Bearer <token>
Accept: application/json
ClaimMeaning
subUser id. The API uses this value as user_id and never accepts an owner id from the request body.
scopeSpace-separated permissions. Read operations require inventory:read. Write operations require inventory:write.
expExpiry, as a Unix timestamp. Expired tokens are rejected.
StatusCondition
401 UnauthorizedThe header is missing, the token cannot be verified, or the token has expired.
403 ForbiddenThe token is valid and the required scope is absent.

Conventions​

  • Resource ids are opaque strings (pnt_…, min_…, usr_…). Clients treat them as identifiers, not as structured data.
  • Timestamps are UTC, ISO 8601, with a Z suffix. Dates are YYYY-MM-DD.
  • Property names in JSON are camelCase. Column names in the database are snake_case.
  • List responses are paginated. page defaults to 0 and size defaults to 20. size cannot exceed 100.
  • A successful create returns 201 Created and a Location header pointing at the new resource.
  • Clients cannot set id, ownerId, createdAt, or updatedAt. The server assigns those fields.

List acrylic paints​

Returns the authenticated user's acrylic paints. Results can be narrowed by brand.

GET /api/v1/paints

Headers​

HeaderRequiredValue
AuthorizationYesBearer <token> with the inventory:read scope.
AcceptYesapplication/json

Query parameters​

NameRequiredTypeDescription
brandNostringCase-insensitive brand name. Known values: Citadel, Vallejo, Army Painter, AK Interactive, Scale75. Omit the parameter to return every brand.
typeNostringPaint medium. This operation returns acrylic paints only. Any other value produces an empty data array and a 200 status.
pageNointegerZero-based page index. Default: 0.
sizeNointegerPage size. Default: 20. Maximum: 100.

The server always applies user_id = <sub> and type = 'acrylic'. When brand is present it also applies a case-insensitive match on brand.

Request body​

None.

Example request​

GET /api/v1/paints?brand=Citadel&type=acrylic&page=0&size=20 HTTP/1.1
Host: api.minisvault.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfM2Y4YTEiLCJzY29wZSI6ImludmVudG9yeTpyZWFkIn0.signature
Accept: application/json

Response — 200 OK​

{
"data": [
{
"id": "pnt_8c21a4",
"name": "Macragge Blue",
"brand": "Citadel",
"range": "Base",
"type": "acrylic",
"colorCode": "21-08",
"hex": "#0F3D7A",
"finish": "matte",
"volumeMl": 12,
"quantity": 2,
"status": "in-stock",
"acquiredOn": "2026-03-14"
},
{
"id": "pnt_19fe02",
"name": "Game Color Magic Blue",
"brand": "Vallejo",
"range": "Game Color",
"type": "acrylic",
"colorCode": "72.021",
"hex": "#1B4F9C",
"finish": "matte",
"volumeMl": 18,
"quantity": 1,
"status": "low",
"acquiredOn": "2026-07-02"
},
{
"id": "pnt_c4e81b",
"name": "French Blue",
"brand": "AK Interactive",
"range": "3rd Generation",
"type": "acrylic",
"colorCode": "AK11186",
"hex": "#3E6BA8",
"finish": "matte",
"volumeMl": 17,
"quantity": 2,
"status": "in-stock",
"acquiredOn": "2026-08-21"
}
],
"page": {
"number": 0,
"size": 20,
"totalElements": 3,
"totalPages": 1
}
}

Response fields​

FieldTypeDescription
dataarrayAcrylic paints owned by the caller. Empty when none match.
data[].idstringPaint id.
data[].namestringDisplay name.
data[].brandstringManufacturer brand.
data[].rangestringProduct line within the brand. Maps to paints.range_name.
data[].typestringAlways acrylic on this operation.
data[].colorCodestringManufacturer color code. Maps to paints.color_code.
data[].hexstringColor as #RRGGBB.
data[].finishstringmatte, satin, gloss, or metallic.
data[].volumeMlnumberBottle volume in millilitres. Maps to paints.volume_ml.
data[].quantityintegerBottles on hand. 0 or greater.
data[].statusstringin-stock, low, or empty.
data[].acquiredOnstringDate the bottle was acquired, YYYY-MM-DD. Maps to paints.acquired_on.
page.numberintegerPage index returned.
page.sizeintegerPage size applied.
page.totalElementsintegerMatching paints across all pages.
page.totalPagesintegerPage count for the current filter.

Errors​

StatusWhen
401 UnauthorizedThe token is missing or invalid.
403 ForbiddenThe token is valid and lacks inventory:read.

Add a miniature​

Creates a miniature for the authenticated user.

POST /api/v1/miniatures

Headers​

HeaderRequiredValue
AuthorizationYesBearer <token> with the inventory:write scope.
Content-TypeYesapplication/json
AcceptYesapplication/json

Request body​

{
"name": "Primaris Captain",
"faction": "Ultramarines",
"gameSystem": "Warhammer 40,000",
"manufacturer": "Games Workshop",
"scale": "28mm",
"status": "unpainted",
"quantity": 1,
"notes": "Boxed, still on sprue."
}
FieldRequiredTypeConstraints
nameYesstring1–120 characters.
factionYesstring1–80 characters.
statusYesstringunpainted, in-progress, or completed.
gameSystemNostringMaximum 80 characters. Maps to miniatures.game_system.
manufacturerNostringMaximum 80 characters.
scaleNostringMaximum 20 characters.
quantityNointegerDefaults to 1. Minimum 1.
notesNostringMaximum 500 characters.

user_id is copied from the token's sub claim. A body that includes id, ownerId, createdAt, or updatedAt does not override the values the server generates.

Example request​

POST /api/v1/miniatures HTTP/1.1
Host: api.minisvault.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfM2Y4YTEiLCJzY29wZSI6ImludmVudG9yeTp3cml0ZSJ9.signature
Content-Type: application/json
Accept: application/json

{
"name": "Primaris Captain",
"faction": "Ultramarines",
"gameSystem": "Warhammer 40,000",
"manufacturer": "Games Workshop",
"scale": "28mm",
"status": "unpainted",
"quantity": 1,
"notes": "Boxed, still on sprue."
}

Response — 201 Created​

Location: /api/v1/miniatures/min_4ab91c
{
"id": "min_4ab91c",
"name": "Primaris Captain",
"faction": "Ultramarines",
"gameSystem": "Warhammer 40,000",
"manufacturer": "Games Workshop",
"scale": "28mm",
"status": "unpainted",
"quantity": 1,
"notes": "Boxed, still on sprue.",
"ownerId": "usr_3f8a1",
"createdAt": "2026-10-10T16:05:00Z",
"updatedAt": "2026-10-10T16:05:00Z"
}
FieldTypeDescription
idstringMiniature id assigned by the server.
ownerIdstringUser id from the token. Maps to miniatures.user_id.
createdAtstringCreation time, UTC. Maps to miniatures.created_at.
updatedAtstringLast update time, UTC. Equal to createdAt on create. Maps to miniatures.updated_at.

The remaining fields echo the stored request values, including defaults.

Errors​

StatusWhen
400 Bad RequestA required field is missing, status is not one of the allowed values, or quantity is below 1.
401 UnauthorizedThe token is missing or invalid.
403 ForbiddenThe token is valid and lacks inventory:write.

A validation failure returns this body:

{
"error": "validation_failed",
"message": "Request body failed validation.",
"details": [
{
"field": "status",
"message": "Must be one of: unpainted, in-progress, completed."
}
]
}
FieldTypeDescription
errorstringMachine-readable code. Validation failures use validation_failed.
messagestringShort explanation safe to log.
detailsarrayOne entry per invalid field.
details[].fieldstringJSON property that failed.
details[].messagestringReason that field was rejected.

Data model​

PostgreSQL stores inventory rows. Each paint and each miniature references users.id. The API exposes camelCase JSON; the columns below are the stored names.

users​

CREATE TABLE users (
id VARCHAR(16) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

paints​

Used by GET /api/v1/paints. The list query is restricted to the caller's rows and to type = 'acrylic'.

CREATE TABLE paints (
id VARCHAR(16) PRIMARY KEY,
user_id VARCHAR(16) NOT NULL REFERENCES users(id),
name VARCHAR(120) NOT NULL,
brand VARCHAR(40) NOT NULL,
range_name VARCHAR(60) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('acrylic', 'enamel', 'oil', 'contrast', 'wash')),
color_code VARCHAR(20),
hex CHAR(7),
finish VARCHAR(20) NOT NULL CHECK (finish IN ('matte', 'satin', 'gloss', 'metallic')),
volume_ml NUMERIC(5,1) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity >= 0),
status VARCHAR(20) NOT NULL CHECK (status IN ('in-stock', 'low', 'empty')),
acquired_on DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_paints_user_brand ON paints (user_id, brand);
ColumnJSON fieldNotes
range_namerange
color_codecolorCode
volume_mlvolumeMl
acquired_onacquiredOn
user_id—Filter only. Omitted from the paint object.
created_at, updated_at—Stored, omitted from the list payload.

Filter applied by the handler:

WHERE user_id = :subject
AND type = 'acrylic'
AND lower(brand) = lower(:brand) -- only when brand is supplied

miniatures​

Used by POST /api/v1/miniatures.

CREATE TABLE miniatures (
id VARCHAR(16) PRIMARY KEY,
user_id VARCHAR(16) NOT NULL REFERENCES users(id),
name VARCHAR(120) NOT NULL,
faction VARCHAR(80) NOT NULL,
game_system VARCHAR(80),
manufacturer VARCHAR(80),
scale VARCHAR(20),
status VARCHAR(20) NOT NULL CHECK (status IN ('unpainted', 'in-progress', 'completed')),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity >= 1),
notes VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_miniatures_user_status ON miniatures (user_id, status);
ColumnJSON field
user_idownerId
game_systemgameSystem
created_atcreatedAt
updated_atupdatedAt