Skip to content

API Reference ​

Version: 0.1.0

Interactive docs ​

With the server running, the API is self-documented via OpenAPI:

URLDescription
http://localhost:9481/api/docsSwagger UI: interactive, try-it-out docs
http://localhost:9481/api/redocReDoc: clean, readable reference
http://localhost:9481/api/openapi.jsonRaw OpenAPI schema

These need a signed-in session by default (open them in a browser where you're signed in). Set API_DOCS to public to serve them to anyone, or off to turn them off.


Authentication ​

All endpoints except /api/auth/status, /api/auth/setup, /api/auth/login, and /api/auth/config require a JWT, or, for external integrations, an API key.

Header (preferred):

Authorization: Bearer <token>

Session cookie (browser images and downloads): sign-in also sets an HttpOnly grimoire_session cookie, which authenticates GET requests. A token in the URL (?token=) is not accepted.

Access tokens are returned by /api/auth/login and expire after 30 minutes; the app renews them from an HttpOnly refresh cookie.

Roles ​

RolePermissions
adminFull access including user management and app settings
gmRead + edit metadata, rescan library, manage campaigns
playerRead-only access

API keys ​

Scripts and tools, such as a Homepage widget, grimoire-cli, or a backup script, authenticate with an API key instead of a login:

X-API-Key: grim_...

Keys are personal. Every key belongs to a user and acts as that user: their role, their campaigns, their favorites. The key's permissions can only narrow that; a key can never do more than its owner. If the owner's role changes, their keys follow. Keys are deleted with their owner.

Create keys under Settings → Connections → API Keys. Admins always can. Anyone else can once an admin ticks API keys on their row in Settings → People (off by default), or your OIDC provider grants the apiKeys permission; taking it away stops their existing keys working. Admins can see and revoke everyone's keys under Settings → Access & sharing → All API Keys. Guests never have keys.

Setting API_KEYS_ENABLED=false turns API keys off for the whole instance, admins included: keys stop working and the key menus disappear.

Each key has a name, an optional expiry (30, 90 or 365 days, or never), and a level per area:

LevelAllows
No access (default)Nothing
ReadViewing: GET requests, plus a few read-only POSTs such as metadata search
Read and writeEverything in that area your account can do, including changes

All permissions sets one level for every area at once, including areas added in future versions. You can still raise individual areas above it.

PermissionCovers
Statistics/api/stats: library counts and totals
LibraryScan status, start or cancel a rescan, clean up missing items, version and changelog
BooksBooks and their metadata, covers, pages and files
Game systemsGame systems, their covers and book folders
SearchFull-text search
TagsCreate, rename, merge and delete tags, and list tagged items
Lookup listsGenres, system families, parent systems, licenses and dice/materials
MapsMaps and map folders
TokensTokens, token folders and token frames
AudioAudio tracks, their covers and folders
3D models3D models and model folders
CampaignsYour campaigns: members, sessions, wiki, resources and schedule
DownloadsZip archive downloads
File managerBrowse, upload, move, rename and delete library files
DuplicatesFind, compare, link and merge duplicates
Add-onsCommunity metadata add-ons
MaintenanceMetadata sidecar settings and export
BackupsBackups and the backup schedule
LogsThe application logs
SettingsApp settings
UsersUser accounts, and your own preferences
PersonalYour favorites, bookmarks, saved filters, saved playlists and soundboards, and themes

You're only offered what your role can use. A player sees game systems as Read at most, since players can't edit them, and never sees admin-only areas like Logs or the file manager.

A few things are never available to a key: signing in, managing API keys, and changing a credential or the account itself (password, deleting the account, OPDS and calendar feed tokens). Guest invite codes (creating, listing, regenerating and sharing them), the OIDC sign-in button icon, the add-on source settings (PATCH /api/addons/settings) and private source tokens (/api/addons/sources/credential) are session-only too.

Session-only fields. Some routes a key can use take a few fields that only a signed-in session may set. Sent with a key they get a 403 naming the field; the rest of the route works as usual:

  • POST /api/users and PATCH /api/users/:id: password, the admin role, and (on PATCH) a changed email. Converting a guest to a user likewise.
  • PATCH /api/users/me/preferences: a changed email.
  • PATCH /api/settings: password sign-in, guest access, the login message, and every OIDC setting.
  • Approving an add-on's script, on install (approve_script: true) or afterwards (script_approved: true).

Campaign responses never include a guest's invite code when the request uses a key.

Share links with a key. Creating or changing a share link with a key also needs the key to have Read on the item's own area - Books, Maps, Tokens, Audio or 3D models - besides Share links: Read and write.

Shown once. The full key appears only when it's created or regenerated. Grimoire stores just a hash of it and afterwards shows only its first few characters. A lost key can't be recovered, only regenerated.

Errors. An unknown or expired key gets 401. A valid key without the permission a request needs gets 403, with a message naming the permission and level it needs. A key also gets 403 once its owner loses API key access, or while API_KEYS_ENABLED=false. Once an address has sent too many wrong keys, further wrong keys from it get 429; a working key is never refused (see Security).

MCP. With MCP_ENABLED=true, keys also authenticate the MCP server at POST /mcp. Each MCP tool, resource and prompt checks the permission it needs, and a key is only shown the ones it can use.

Calling the API from a browser ​

Server-side tools like Homepage and grimoire-cli need nothing extra. Code that runs in a web page on another site, such as a Foundry VTT module, a browser extension or a dashboard, is blocked by the browser unless you allow that site with CORS_ALLOWED_ORIGINS:

yaml
environment:
  CORS_ALLOWED_ORIGINS: "https://foundry.example.com"

List each origin exactly (scheme://host[:port]), separated by commas. It's off by default. Allowed sites can send Authorization, Content-Type and X-API-Key, and can read X-Token-Expired and error responses. Cookies are never allowed cross-origin, so another site can't use a signed-in user's session: browser integrations authenticate with an API key, which its permissions already limit.


Auth ​

EndpointMethodAuthDescription
/api/auth/statusGETNoneReturns {initialized, password_min_length, share_link_password_min_length}: whether first-run setup is done, and the minimum lengths for new account and share-link passwords
/api/auth/configGETNonePublic auth config for the login screen
/api/auth/setupPOSTNoneFirst-run admin account creation. Body: {username, password}. The password must meet the password rules
/api/auth/loginPOSTNoneAuthenticate. Body: {username, password}. Returns {token, user}. Rate-limited per IP and per account; 429 once either is used up (see Auth rate limiting)
/api/auth/meGETanyCurrent user details
/api/auth/openid/loginGETNoneStart OIDC login, redirects to IdP
/api/auth/openid/callbackGETNoneOIDC callback
/api/auth/openid/discoverPOSTadminServer-side discovery fetch. Body: {issuer_url}

Users ​

EndpointMethodAuthDescription
/api/usersGETadminList all users
/api/usersPOSTadminCreate a user. Body: {username, password, role?, email?}. The password must meet the password rules
/api/users/:idPATCHadminUpdate role, password, allow_explicit, or email
/api/users/:idDELETEadminDelete a user
/api/users/me/preferencesPATCHanyUpdate own display_name, allow_explicit, or email. While OIDC matches existing users by email, only an admin can change an email
/api/users/me/passwordPATCHanyChange own password. Body: {current_password, new_password}. The new password must meet the password rules
/api/users/meDELETEanyDelete own account

Library ​

EndpointMethodAuthDescription
/api/statsGETJWT, or an API key with Statistics: ReadCounts, page totals, library size
/api/scan-statusGETadminCurrent scan state
/api/rescanPOSTadminTrigger a background rescan and reindex
/api/cancel-scanPOSTadminGracefully stop the running scan

Stats response:

json
{
  "game_systems": 12,
  "books": 340,
  "maps": 1500,
  "tokens": 800,
  "audio": 250,
  "models": 640,
  "indexed_books": 320,
  "total_pages": 45000,
  "total_size_mb": 18240.5,
  "library_size_mb": 41890.2
}

To show library counts on an external dashboard, create an API key with Statistics: Read and nothing else. The counts are the key owner's, so use an admin's key to count the whole library. See the Homepage Widget guide for a full walkthrough.

Homepage Custom API widget: add this to your Homepage services.yaml (Custom API widget docs):

yaml
- Grimoire:
    href: https://grimoire.example.com
    icon: grimoire.png
    widget:
      type: customapi
      url: https://grimoire.example.com/api/stats
      refreshInterval: 60000
      headers:
        X-API-Key: your-key-here
      mappings:
        - field: books
          label: Books
          format: number
        - field: maps
          label: Maps
          format: number
        - field: tokens
          label: Tokens
          format: number
        - field: library_size_mb
          label: Size
          format: float
          scale: 0.001
          suffix: " GB"

Homepage shows up to four fields per row; pick the counts you care about from the fields below. A refreshInterval of 60s is plenty, since the counts only change when the library does.

FieldMeaningSuggested format
game_systemsNumber of game systems (top-level library folders)number
booksTotal books (PDFs)number
mapsTotal mapsnumber
tokensTotal tokensnumber
audioTotal audio tracksnumber
modelsTotal 3D modelsnumber
indexed_booksBooks with a searchable full-text indexnumber
total_pagesSum of all book page countsnumber
total_size_mbSize of the books in MB (books only)float, add scale: 0.001 + suffix: " GB" to show GB
library_size_mbSize of the whole library in MB — books plus maps, tokens, audio, and modelsfloat, add scale: 0.001 + suffix: " GB" to show GB

Game Systems ​

EndpointMethodAuthDescription
/api/systemsGETanyList all systems with book counts
/api/systems/:idGETanySystem detail + full book list. include_books=false returns the summary alone
/api/systems/:id/booksGETany (not guest)One page of the system's books matching the shelf filters. Query: filters, category + folder (one folder of the grouped shelf), sort, order, limit, offset
/api/systems/:id/book-groupsGETany (not guest)Every category and subfolder holding matching books, with counts
/api/systems/:id/book-facetsGETany (not guest)The tags, genres, product-code prefixes and categories on the shelf
/api/systems/:idPATCHgm/adminUpdate metadata

PATCH fields: name, slug, description, publishers, character_builder_url, cover_image, cover_book_id, tags, genre, is_explicit


Books ​

EndpointMethodAuthDescription
/api/booksGETanyPaginated book list. Query: system_id, category, limit (max 500), offset, sort (title or added_at), order (asc/desc; added_at defaults to newest first), added_since (ISO-8601, UTC if no offset). Each book carries added_at - when it first appeared in the library, or null for items from the first import of a new install (and older rows with no date). Poll sort=added_at&added_since=<last poll> to find new additions.
/api/books/:idGETanyBook detail
/api/books/:idPATCHgm/adminUpdate book metadata
/api/books/:id/reindexPOSTgm/adminRe-run OCR on a scanned book. Optional query ocr_dpi (72–600) re-reads it at a higher resolution than the global OCR_DPI. Clears the search index and re-queues the book (OCR runs in the background). 400 for books with an embedded text layer.
/api/books/:id/rescanPOSTgm/adminRe-read a single book from disk and rebuild its search index, for a file edited externally. Works for any PDF: a text-layer book is re-extracted; an image-only book is re-queued for OCR. Refreshes page count and thumbnail. Runs in the background; no-ops if a library scan is already running. 400 for non-PDFs.
/api/books/:id/fileGETanyDownload/stream the file
/api/books/:id/thumbnailGETanyWebP cover thumbnail
/api/books/:id/tocGETanyPDF table of contents
/api/books/:id/page/:numGETanyRender PDF page as WebP. Query: width (default 1200, 16-3000; outside that range is 422)
/api/books/:id/page/:num/textGETanyPlain text of a page
/api/books/:id/page/:num/wordsGETanyWord bounding boxes for text overlay

Maps ​

EndpointMethodAuthDescription
/api/mapsGETanyPaginated map list. Query: limit, offset, map_type, and the browse parameters
/api/maps/groupsGETany (not guest)Folders holding matching maps, with counts
/api/maps/:idGETanyMap detail
/api/maps/:idPATCHgm/adminUpdate description, tags, map_type, grid_size
/api/maps/:id/fileGETanyDownload/stream the original map file
/api/maps/:id/page/:nGETanyDownscaled WebP render. Query: width (default 1600, 16-3000; outside that range is 422)
/api/maps/:id/vtt/imageGETanyBattlemap image decoded out of a .uvtt/.dd2vtt file
/api/maps/:id/vtt/dataGETanyGrid resolution and wall/portal/light counts from a Universal VTT file
/api/maps/:id/export.uvttGETanyBuilds a Universal VTT file for a raster map: the image, the grid, and anything authored in the editor
/api/maps/:id/otfbmGETmay shareHow to set the map up as an OTFBM background: the whole-cell map size, cell size (?cell= 20–100, defaults to a suggestion), and the resized image size
/api/maps/:id/otfbm/imageGETmay shareThe map resized so each cell is ?cell= pixels, as a JPEG (PNG when transparent), named <slug>-<W>x<H>c<cell>
/api/maps/:id/vtt/authoringGETanyAuthored walls, portals, lights and environment, with the resolved grid. data is null when nothing is authored
/api/maps/:id/vtt/authoringPUTgm/adminReplaces a map's authored geometry in one atomic write. Body: {data} — send {"data": null} to clear
/api/maps/:id/thumbnailGETanyWebP thumbnail
/api/map-foldersGETanyList folder tag assignments
/api/map-foldersPATCHgm/adminSet tags on a folder. Body: {path, tags}

Map formats and viewing ​

Map detail responses carry a media_kind field naming the viewer to use: image, video, vtt, or archive.

Raster maps are not viewed through /file. The viewer requests /page/1, which renders a downscaled WebP — a large battlemap would otherwise have to be transferred in full before anything appeared. /file remains the download endpoint and serves the untouched original.

Animated battlemaps (.webm, .mp4) stream from /file with a real video MIME type and no attachment disposition, so they play in place.

Universal VTT files (.uvtt, .dd2vtt) are JSON envelopes holding the map as base64 plus wall, portal, and light data (format reference). The image is decoded server-side and served by /vtt/image; /vtt/data returns the grid and feature counts with the image omitted, so the base64 payload never reaches the browser.

Universal VTT authoring ​

Walls, doors, windows, and lights drawn in the in-app editor are stored against the map in Grimoire's database, not written to the library. Coordinates are in grid units rather than pixels, which is why recalibrating the grid moves the geometry with it.

PUT /vtt/authoring replaces the whole document atomically — there is no per-feature endpoint — and the .uvtt is assembled on demand by /export.uvtt. The original file on disk is never modified, so authoring works against a read-only library mount, and editing a map that is a .uvtt leaves that file untouched while the export carries its embedded image alongside the new geometry.


Tokens ​

EndpointMethodAuthDescription
/api/tokensGETanyPaginated token list. Query: limit, offset, and the browse parameters
/api/tokens/groupsGETany (not guest)Folders holding matching tokens, with counts
/api/tokens/:idGETanyToken detail
/api/tokens/:idPATCHgm/adminUpdate description, tags, is_explicit
/api/tokens/:id/fileGETanyDownload the token image
/api/tokens/:id/thumbnailGETanyWebP thumbnail
/api/token-foldersGETanyList folder tag assignments, plus frame_folders — paths (any depth under tokens/) holding a .frames-container marker
/api/token-foldersPATCHgm/adminSet tags on a folder. Body: {path, tags}
/api/token-framesGETany (not guest)List user-supplied token editor frames
/api/token-frames/:id/fileGETany (not guest)Serve one frame image

Frames are the .png/.webp/.svg files in any folder holding a .frames-container marker file under tokens/. The built-in frames ship with the frontend and are not listed here. A frame id is the base64url-encoded library-relative path, and is revalidated on every request — it must resolve inside the library, sit in a marked folder reached through non-hidden folders, and carry an allowed extension. Anything else is a 404.

Each listed frame also carries token_id — the id of the Token row indexed from the same file, or null when the scanner has not reached it yet. There is no separate "frame" favourite type: a frame is favourited by starring its token, and this is the join the editor uses to group favourited frames.


Models ​

EndpointMethodAuthDescription
/api/modelsGETanyPaginated 3D model list. Query: limit, offset, and the browse parameters
/api/models/groupsGETany (not guest)Folders holding matching models, with counts
/api/models/:idGETanyModel detail, including viewer capability
/api/models/:idPATCHgm/adminUpdate description, tags, is_explicit, is_supported
/api/models/:id/fileGETanyDownload the model file
/api/models/:id/thumbnailGETanyWebP thumbnail (rendered preview)
/api/models/bulkPOSTgm/adminBulk update models
/api/models/bulk/tagsPOSTgm/adminBulk add tags to models
/api/model-foldersGETanyList folder tag assignments
/api/model-foldersPATCHgm/adminSet tags on a folder. Body: {path, tags}
/api/model-folders/bulkPOSTgm/adminSet tags on many folders

The detail response carries three viewer fields. viewer_loader names the client-side loader for the format (stl, gltf, 3mf, ply) and is empty when none applies. viewer_available says whether the browser viewer should load the file on its own. viewer_oversized distinguishes the two reasons it might not: when true, the format is supported and only the size cap held it back, so the client can offer to load it anyway behind a warning; when false alongside a false viewer_available, no loader exists for the format and a download is the only option.

is_supported is a tri-state — true presupported, false unsupported, null when the name says nothing. See the 3D Models guide.


Browsing large collections ​

The gallery list routes (/api/maps, /api/tokens, /api/audio, /api/models) and their /groups companions filter, sort and group on the server, so a client can browse a library of any size a page at a time. They share these parameters:

ParameterMeaning
qSearch text over the name, folder path and tags; accepts field: prefixes (tag:, title:, artist:, album:)
tagsTag filter as JSON: [{"mode": "include"|"exclude", "tags": [...]}] groups (OR within a group, AND between groups), or a flat list. Folder tags count for everything beneath the folder. __grim:none__ / __grim:any__ match untagged / tagged items
favoritestrue for the caller's favourites only
added_sinceItems added at or after this ISO time
folderOnly items directly in this folder ("" for the collection root)
sort, orderpath (default), name (natural order), size, added_at; audio also title, duration, track (folder, then disc and track number, then name). asc or desc

/groups returns {total, groups: [{path, count}]} - every folder holding matching items. A tag's items are paged the same way: /api/tags/:internal/items returns per-type counts, the tagged folders with counts, and a page of directly-tagged items (resource_type, limit, offset), and /api/tags/:internal/folder-items pages through one tagged folder.


EndpointMethodAuthDescription
/api/search?q=GETanyFTS5 full-text search. Min 2 chars. Optional: book_id, system_id, limit

Response:

json
{
  "query": "fireball",
  "total": 42,
  "results": [{"id": "uuid", "title": "...", "game_system": "...", "page_number": 42, "snippet": "..."}],
  "maps":    [{"id": "uuid", "filename": "...", "relative_path": "...", "tags": []}],
  "tokens":  [{"id": "uuid", "filename": "...", "relative_path": "...", "tags": []}]
}

maps and tokens are empty when book_id or system_id is scoped.


Favorites ​

EndpointMethodAuthDescription
/api/favoritesGETanyList current user's favorites
/api/favoritesPOSTanyAdd a favorite (idempotent). Body: {item_type, item_id}
/api/favorites/:type/:idDELETEanyRemove a favorite

Item types: book, map, token, system


Bookmarks ​

EndpointMethodAuthDescription
/api/bookmarks?book_id=GETanyList user's bookmarks for a book
/api/bookmarksPOSTanyCreate a bookmark. Body: {book_id, page_number, label?, notes?, selected_text?}
/api/bookmarks/:idPATCHanyUpdate label or notes
/api/bookmarks/:idDELETEanyDelete a bookmark

Saved audio sets ​

Named, per-user playlists and soundboards. See Audio Library.

EndpointMethodAuthDescription
/api/audio-setsGETanyList the user's saved sets (names and counts only). Optional ?kind= limits to one kind
/api/audio-setsPOSTanySave a set. Body: {kind, name, entries: [{audio_id, loop?}], layout?: {cols, rows}}
/api/audio-sets/:idGETanyLoad one set, resolved against the current library
/api/audio-sets/:idPATCHanyRename or replace contents. Body: {name?, entries?, layout?}
/api/audio-sets/:idDELETEanyDelete a saved set

Kinds: playlist, soundboard. layout is the soundboard grid and is null for a playlist. Re-saving an existing (kind, name) overwrites that set rather than creating a second one.

Entries store audio ids only, so titles come back resolved against the library as it is now. An entry whose track has been removed is omitted and counted in missing, letting a stale set load with what remains instead of failing.


Campaigns ​

Campaign CRUD ​

EndpointMethodAuthDescription
/api/campaignsGETanyList own + invited campaigns
/api/campaignsPOSTanyCreate campaign
/api/campaigns/:idGETowner or memberCampaign detail
/api/campaigns/:idPATCHowner or adminUpdate campaign
/api/campaigns/:idDELETEowner or adminDelete campaign

Members ​

EndpointMethodAuthDescription
/api/campaigns/:id/invitePOSTowner or adminInvite a user. Body: {user_id}
/api/campaigns/:id/members/:user_idPATCHmember or ownerAccept/decline or set character name
/api/campaigns/:id/members/:user_idDELETEowner, admin, or selfRemove member

Sessions ​

EndpointMethodAuthDescription
/api/campaigns/:id/sessionsGETmember or ownerList sessions
/api/campaigns/:id/sessionsPOSTmember or ownerCreate session. Body: {session_date, title?}
/api/campaigns/:id/sessions/:sidPATCHowner or adminUpdate title
/api/campaigns/:id/sessions/:sidDELETEowner or adminDelete session
/api/campaigns/:id/sessions/:sid/notes/playerPUTmember or ownerSave player note. Body: {content}
/api/campaigns/:id/sessions/:sid/notes/gmPUTowner or adminSave GM notes. Body: {internal_content?, external_content?}

Schedule ​

EndpointMethodAuthDescription
/api/campaigns/:id/scheduleGETmember or ownerSchedule + next 10 dates
/api/campaigns/:id/schedulePUTowner or adminCreate or update schedule
/api/campaigns/:id/scheduleDELETEowner or adminRemove schedule

Schedule body:

json
{
  "frequency": "weekly",
  "days": [5],
  "time_utc": "18:00",
  "biweekly_reference": "2026-01-03",
  "monthly_week": null,
  "custom_dates": null
}

frequency values: weekly, biweekly, monthly, custom. Days are 0 (Monday) – 6 (Sunday).

Availability ​

EndpointMethodAuthDescription
/api/campaigns/:id/availabilityGETmember or ownerAvailability for next 10 scheduled sessions
/api/campaigns/:id/availability/:datePUTmember or ownerSet own availability. Body: {status}
/api/campaigns/:id/availability/:date/cancelPUTowner or adminToggle session cancellation

Statuses: available, tentative, unavailable


Characters ​

Sheets (schemas) and characters are stored per user, so any signed-in user — guests included — may install a sheet. See Characters for how the builder works.

Sheets ​

EndpointMethodAuthDescription
/api/characters/schemasGETanyInstalled sheets, each with a character_count
/api/characters/schemasPOSTanyInstall a pasted sheet. Body: {text} (JSON or YAML, without YAML aliases, at most 512 KB and 100 levels deep) or {document}, plus optional layout and styles. 400 names the problem if it does not validate
/api/characters/schemas/browseGETanyThe community sheet catalogue, each marked installed
/api/characters/schemas/install/:sheet_idPOSTanyInstall a sheet from the catalogue
/api/characters/schemas/:schema_idGETanyOne sheet with its validated document
/api/characters/schemas/:schema_idDELETEanyUninstall a sheet. Characters built on it are kept

Characters ​

EndpointMethodAuthDescription
/api/charactersGETanyYour characters plus your parties', newest first. ?schema_ref= and ?campaign_id= filter
/api/charactersPOSTanyCreate one. Body: {schema_ref, name?, data?, campaign_id?}
/api/characters/:idGETowner or partyOne character with data and freshly worked-out computed
/api/characters/:idPUTownerUpdate name, data (a partial patch), unset, campaign_id ("" clears it) and/or status
/api/characters/:idDELETEownerDelete a character
/api/characters/:id/exportGETowner or partyA self-contained file, with the sheet and every referenced entry embedded
/api/characters/importPOSTanyRebuild a character from an exported file. Body: {payload, import_entries?}
/api/characters/importersGETany{importers, available, downloads_enabled}: the importers you can run (scope server or user, the sheet each builds on, extensions, whether it reads files and links, its inputs), and catalogue importers you lack with install_as - user, server, settings or admin
/api/characters/importers/:id/installPOSTanyInstall a file-only YAML importer for yourself, or update your copy
/api/characters/importers/:idDELETEanyRemove an importer you installed
/api/characters/import/externalPOSTanyBuild a character from another tool's export. Body: {text, filename} for a file or {url} for a link, plus optional importer_id, campaign_id and inputs ({key: value} answers to the importer's declared inputs, never stored). The importer is recognised automatically unless named; 409 when several claim it. A missing input answers 400 with detail: {message, importer_id, needs_input}. Installs the importer's sheet from the catalogue if you lack it. Returns the character plus importer_id, importer_name and unmatched (names kept as custom entries)
/api/characters/:id/portraitGET / POST / DELETEread: owner or party; write: ownerThe character's art (PNG, JPEG, WebP or GIF, 5 MB)
/api/characters/:id/images/:fieldGET / POST / DELETEread: owner or party; write: ownerThe picture a sheet's image field holds (same types and size). POST returns {field, value}; value is the stored file name, also the URL's ?v= cache-buster

Character fields: id, name, schema_ref, schema_name, system, schema_missing, campaign_id, campaign_name, status, portrait_path, portrait_version, portrait_crop ({x, y, zoom} - the focus point in percent and a 1-4 zoom; null is centred, and PUT with {} clears it), owned, created_at, updated_at, and on detail data, computed, validators, entries.

Campaigns. A character can be placed in a campaign you own (a GM campaign or a personal one) or have joined — 403 otherwise, including for an invitation you have not accepted. Everyone at that table — the owner and accepted members — can then read the sheet; only its owner can change it (owned is false for anyone else). A player may have any number of characters in one campaign.

Status is active (the default), retired or dead, so a fallen character can stay beside its replacement. Any other value is a 400.

Content catalog ​

EndpointMethodAuthDescription
/api/content/packsGETanyInstalled content packs with their licence and credit
/api/content/packs/reloadPOSTadminRe-read every pack from disk
/api/content/:schema_id/typesGETanyThe content types a sheet declares, with entry counts
/api/content/:schema_id/:content_typeGETanyBrowse: search, filter[field]=value, sort, page, page_size
/api/content/:schema_id/:content_type/:entry_idGETanyOne entry in full

Rulesets ​

A campaign ruleset is read by everyone at that table and edited by the campaign's owner; a server ruleset (campaign_id null) is read by everyone and edited by admins; a personal ruleset (personal: true) is read and edited by its owner alone. Importing a character creates a personal ruleset for the content embedded in its file.

EndpointMethodAuthDescription
/api/rulesetsGETanyRulesets you can read. schema_id and campaign_id filter
/api/rulesetsPOSTcampaign owner, or admin for a server rulesetCreate one
/api/rulesets/packs/browseGETanyThe community content-pack catalogue, with installed and update_available
/api/rulesets/packs/install/:pack_idPOSTadminInstall or update a content pack
/api/rulesets/packs/:pack_idDELETEadminUninstall a content pack
/api/rulesets/:idGET / PUT / DELETEread: reader; write: editorOne ruleset
/api/rulesets/:id/exportGETreaderThe ruleset as a portable document
/api/rulesets/:id/importPOSTeditorImport a pack_id, a document, or text (JSON or YAML). conflict: skip (default), overwrite, rename
/api/rulesets/:id/forkPOSTeditorCopy an entry into this ruleset to change it
/api/rulesets/:id/entriesGET / POSTread: reader; write: editorList or add entries
/api/rulesets/:id/entries/:entry_idGET / PUT / DELETEread: reader; write: editorOne entry

Settings (admin only) ​

EndpointMethodDescription
/api/settingsGETGet all application settings
/api/settingsPATCHUpdate application settings
/api/settings/uiGETUI visibility flags (any authenticated user)

API keys ​

Manage your API keys. These need a login: a key can never manage keys.

EndpointMethodDescription
/api/api-keysGETList your keys (never the key itself). Admins can add ?all=true for everyone's
/api/api-keys/permissionsGETThe permissions your role can grant, with descriptions and the levels each offers
/api/api-keysPOSTCreate a key. Body: {name, permissions?, expires_at?}, where permissions is {permission: "none" | "read" | "write"} and "*" means all permissions. Returns {api_key, key}: key is the full key, returned only here and by regenerate
/api/api-keys/:idPATCHRename your key or change its permissions or expiry (expires_at: null means never)
/api/api-keys/:id/regeneratePOSTIssue a new key with the same name and permissions. The old key stops working immediately
/api/api-keys/:idDELETERevoke your key. Admins can revoke anyone's

Public, revocable links to one book, map, token, audio track or 3D model - see Share links. Managing them needs a login or an API key with the Share links permission; guests never can.

EndpointMethodDescription
/api/share-linksGETYour links, newest first, each with its state (active, disabled, revoked, expired, suspended, unavailable). Filter with resource_type and resource_id
/api/share-linksPOSTCreate a link. Body: {resource_type, resource_id, label?, expires_in_days?, password?, variant?, cell?}, where resource_type is book, map, token, audio or model and expires_in_days: null means never. variant: "otfbm" (maps only) serves the map resized as an OTFBM background at cell px per square. Returns the link with url (the share page) and file_url (the raw file)
/api/share-links/permissionGETWhether you may create links, and whether that comes from your role or a per-user setting
/api/share-links/:idPATCHChange the label, expiry or password (password: null removes it)

A new link password must be at least SHARE_LINK_PASSWORD_MIN_LENGTH characters (8 by default) and not a commonly used password. | /api/share-links/:id/revoke | POST | Stop a link working for good. It stays listed as revoked | | /api/share-links/:id | DELETE | Remove a link | | /api/share-links/all | GET | Every user's links (admin only) | | /api/share-links/users/:user_id | GET | One user's links (admin only) |

The public side needs no authentication at all - the token is the credential:

EndpointMethodDescription
/api/share/:token/metaGETWhat the share page shows. For a password-protected link, only password_required and unlocked until it is unlocked
/api/share/:token/unlockPOSTBody {password}. Unlocks the link in this browser for 12 hours. Wrong passwords are limited per IP and per link (ACCOUNT_RATE_LIMIT), and get 429 past either
/api/share/:token/:filenameGET, HEADThe item itself (?download=1 for an attachment). The filename is ignored, and is there for tools that read a file's type from the URL

Every way a link can fail to work - unknown, expired, revoked, suspended, or the item gone - is the same 404. Responses allow cross-origin reads (Access-Control-Allow-Origin: *), so VTTs such as Owlbear Rodeo can load them.


Maintenance (admin only) ​

EndpointMethodDescription
/api/maintenance/cleanup-missingPOSTRemove DB records for files no longer present on disk

Backups (admin only) ​

EndpointMethodDescription
/api/backupsGETList backups, newest first, with created_at, size_bytes, and version
/api/backupsPOSTCreate a backup now
/api/backups/settingsGETRead backup schedule, retention, and storage location
/api/backups/settingsPUTConfigure schedule, retention, and storage location
/api/backups/{backup_id}/downloadGETDownload a backup archive (application/zip)
/api/backups/{backup_id}DELETEDelete a backup archive (204)

A backup is a timestamped .zip holding a consistent snapshot of the database plus the files uploaded through Grimoire. The library is never included. Because the listing carries timestamps, a client can check how stale the newest backup is and take a fresh one before running something destructive.

Creating a backup pauses database writes for the length of the snapshot; a second concurrent create returns 409.

There is no restore endpoint, by design. See Backups for the restore procedure and configuration.


Logs (admin only) ​

EndpointMethodDescription
/api/logsGETRetrieve recent log entries from the in-memory ring buffer

Query parameters: level (default info), limit (default 200, max 20000), offset, after_seq (cursor for live polling).


Error responses ​

json
{"detail": "Human-readable error message"}
StatusMeaning
400Bad request / business rule violated
401Not authenticated
403Forbidden, insufficient role
404Not found
409Conflict, duplicate resource
422Request body failed schema validation

Released under the GNU GPL v3.0 License.