HomeLooking for GamesMarketplaceForumWikiContact
Sign up
Log in
Sign up
Log in

Get notified at launch

Drop your email and we'll send you an alert.

Advanced Ruleset Creation

Advanced Ruleset Creation Tips

This article covers tips for ruleset developers regarding how you can leverage our Ruleset Compiler script to speed up ruleset iteration, and more examples of how the underlying data is structured in Ream VTT.

Before reading this guide, be sure to first familiarze yourself with all aspects of the Ruleset Editor in the prior article: Ruleset Editor and API.

Don't forget to explore the Settings tab of a ruleset to understand how things like the Common and Utilities Scripts works, as wells as all the other settings here.

EditorScreenshot.pngYou can also refer to examples from any of our public repos from other rulesets:

  • D&D 5.5e (Complex): https://github.com/seansps/realmvtt-5e

  • Pathfinder 2e (Very Complex): https://github.com/seansps/realmvtt-pf2e

  • Cyberpunk RED (Complex): https://github.com/seansps/realmvtt-cpr

  • Call of Cthulhu 7e (Simpler): https://github.com/seansps/realmvtt-coc7e

Table of Contents

  1. Ruleset Compiler — installation, CLI usage, and the ruleset.config.json reference

  2. Understanding Record Data — anatomy of a record, fields vs data, and embedded list records

  3. Using the Record API Directly — auth, CRUD endpoints, query operators, and import patterns

Ruleset Compiler

The Ruleset Compiler is a CLI tool that lets you maintain a Realm VTT ruleset as a local folder of HTML, JavaScript, and JSON files — then compile and push it to the Realm VTT API with a single command. It is highly recommended that you build your rulesets in an editor and store the code in source control, so that you can easily roll back and fix problems later.

To get the Ruleset Compiler script, pull the following GitHub repository: https://github.com/seansps/ruleset-compiler

With this script, instead of editing every tab, roll-handler, and setting through the in-app ruleset editor, you can keep your ruleset under version control (git), edit it in your favorite editor, and publish changes whenever you're ready.

  • Source: tools/ruleset-compiler

  • Requires: Node.js 18 or later (uses native fetch)


Installation

Clone the realmvtt-rulesets repository and install dependencies:

git clone https://github.com/realmvtt/realmvtt-rulesets.git
cd realmvtt-rulesets/tools/ruleset-compiler
npm install

Quick Start

From the tools/ruleset-compiler directory:

# Compile and upload (interactive — prompts for credentials and target ruleset)
node src/cli.js ../../my-ruleset

# Compile to a file (no upload — useful for inspection or CI)
node src/cli.js ../../my-ruleset --output build.json

# Dry run — print the compiled JSON to stdout
node src/cli.js ../../my-ruleset --dry-run

# Upload to a specific ruleset by ID
node src/cli.js ../../my-ruleset -e [email protected] -p yourpassword -i 64a1b2c3d4e5f6

# Create a brand-new ruleset
node src/cli.js ../../my-ruleset --new

If you omit both --id and --new, the tool fetches the list of rulesets you own and lets you pick one interactively.

Command-line options

Option

Description

-e, --email <email>

Realm VTT account email

-p, --password <pass>

Realm VTT account password

-t, --token <token>

JWT token (bypasses login)

-i, --id <rulesetId>

Update this ruleset by ID

--new

Create a new ruleset instead of updating

--url <url>

API base URL (default: https://utilities.realmvtt.com)

--dry-run

Build JSON and print to stdout — does not upload

--output <file>

Build JSON and write to a file — does not upload

-v, --verbose

Verbose logging


Directory Layout

A ruleset directory is just a folder containing a ruleset.config.json and any HTML/JS files it references:

my-ruleset/
  ruleset.config.json
  character-main.html
  character-skills.html
  character-combat.html
  items-main.html
  abilities-main.html
  skill-list.html
  inventory-list.html
  attack-list.html
  rollhandlers/
    common.js
    skill.js
    damage.js
    attack.js
  scripts/
    on-roll-initiative.js
    damage-apply.js
    healing-apply.js
    rest.js

File paths inside ruleset.config.json are resolved relative to the ruleset directory.


ruleset.config.json

This is the single source of truth. It defines the ruleset's name, all record types, and the settings object (rollhandlers, damage, effects, combat tracker, party sheet, etc.).

Anywhere in the config you can replace a string value with { "file": "relative/path" } and the compiler will inline that file's contents at build time. This works for:

  • Tab HTML — compiled to the tab's layout field

  • Roll handlers — compiled to the roll type's handleResult field

  • Any other script field — replaced with the file's raw text

Top-level fields

Field

Type

Description

name

string

Ruleset display name

description

string

Short description

version

number

Version number

records

array

Record type definitions

settings

object

Ruleset settings (rollTypes, damage, effects, etc.)

Records

Each entry in records is either a standard record (a full sheet with tabs — characters, items, etc.) or a list record (an embedded list used inside another record — inventory list, attack list, etc.).

List records set "isList": true and support extra fields like showAddButton, allowedListTypes, filterCriteria, and orderCriteria.

Full example

Here's a complete ruleset.config.json you can copy as a starting point. It defines a small ruleset with characters, NPCs, items, abilities, a few list types, and wires up rollhandlers + scripts from external files.

{
  "name": "My Custom RPG",
  "description": "A custom tabletop RPG ruleset for Realm VTT",
  "version": 1,

  "records": [
    {
      "name": "Characters",
      "type": "characters",
      "minX": 550,
      "minY": 600,
      "tabs": [
        { "name": "Main", "file": "character-main.html" },
        { "name": "Skills", "file": "character-skills.html" },
        { "name": "Combat", "file": "character-combat.html" },
        { "name": "Inventory", "file": "character-inventory.html" },
        { "name": "Notes", "file": "character-notes.html" }
      ],
      "hideFromCompendium": false,
      "isList": false,
      "icon": "",
      "filters": {}
    },
    {
      "name": "NPCs",
      "type": "npcs",
      "minX": 500,
      "minY": 500,
      "tabs": [
        { "name": "Main", "file": "npcs-main.html" },
        { "name": "Inventory", "file": "npcs-inventory.html" },
        { "name": "Description", "file": "npcs-description.html" }
      ],
      "isList": false,
      "icon": ""
    },
    {
      "name": "Items",
      "type": "items",
      "tabs": [
        { "name": "Main", "file": "items-main.html" },
        { "name": "Rules", "file": "items-rules.html" }
      ],
      "icon": "IconBox"
    },
    {
      "name": "Abilities",
      "type": "abilities",
      "tabs": [
        { "name": "Main", "file": "abilities-main.html" },
        { "name": "Rules", "file": "abilities-rules.html" }
      ],
      "icon": "IconStar"
    },
    {
      "name": "Skill List",
      "type": "skill_list",
      "isList": true,
      "showAddButton": true,
      "showDeleteButton": true,
      "addButtonText": "Add Skill",
      "newItemName": "New Skill",
      "disableDrop": true,
      "tabs": [{ "name": "Main", "file": "skill-list.html" }]
    },
    {
      "name": "Attack List",
      "type": "attack_list",
      "isList": true,
      "disableDrop": true,
      "emptyListText": "Equip weapons on the Inventory tab",
      "filterCriteria": [
        { "field": "type", "operator": "equals", "value": "weapon", "isOr": false },
        { "field": "carried", "operator": "equals", "value": "equipped", "isOr": false }
      ],
      "tabs": [{ "name": "Main", "file": "attack-list.html" }]
    },
    {
      "name": "Inventory List",
      "type": "inventory_list",
      "isList": true,
      "showAddButton": true,
      "showDeleteButton": true,
      "addButtonText": "Add Item",
      "newItemName": "New Item",
      "emptyListText": "Drop Items Here",
      "allowedListTypes": ["items"],
      "orderCriteria": [{ "field": "name", "descending": false }],
      "tabs": [{ "name": "Main", "file": "inventory-list.html" }]
    }
  ],

  "settings": {
    "healthIndicator": {
      "disabled": false,
      "maxHealthField": "maxHp",
      "currentHealthField": "curHp",
      "color": [
        { "percentage": 0, "color": "#4A0000", "label": "Dead" },
        { "percentage": 1, "color": "#8B0000", "label": "Critical" },
        { "percentage": 25, "color": "#CC4400", "label": "Bloodied" },
        { "percentage": 50, "color": "#FF8C00", "label": "Injured" },
        { "percentage": 75, "color": "#B8860B", "label": "Hurt" },
        { "percentage": 100, "color": "#00FF00", "label": "Healthy" }
      ]
    },
    "combatTracker": {
      "initiative": "initiative",
      "order": "desc",
      "slotBased": false,
      "clearSlotsPerRound": false,
      "additionalFields": [],
      "onRollInitiative": { "file": "scripts/on-roll-initiative.js" },
      "onRollInitiativeGroup": { "file": "scripts/on-roll-initiative-group.js" },
      "onTokenAdd": { "file": "scripts/on-token-add.js" },
      "onEncounterStart": { "file": "scripts/on-encounter-start.js" },
      "onEncounterEnd": { "file": "scripts/on-encounter-end.js" },
      "onTurnStart": { "file": "scripts/on-turn-start.js" },
      "onTurnEnd": { "file": "scripts/on-turn-end.js" },
      "onRoundStart": { "file": "scripts/on-round-start.js" },
      "onRoundEnd": { "file": "scripts/on-round-end.js" }
    },
    "rollTypes": [
      { "name": "skill", "file": "rollhandlers/skill.js" },
      { "name": "damage", "file": "rollhandlers/damage.js" },
      { "name": "attack", "file": "rollhandlers/attack.js" }
    ],
    "damage": {
      "enableDamage": true,
      "enableHealing": true,
      "damageScript": { "file": "scripts/damage-apply.js" },
      "healingScript": { "file": "scripts/healing-apply.js" }
    },
    "otherSettings": {
      "commonScript": { "file": "rollhandlers/common.js" },
      "scripts": {
        "rest": { "file": "scripts/rest.js" }
      },
      "defaultDice": "default",
      "defaultUnitsPerSquare": 5,
      "defaultUnits": "feet"
    },
    "partySheet": {
      "awards": {
        "enabled": true,
        "xpName": "XP",
        "xpField": "xp",
        "xpMethod": "award_equally"
      }
    },
    "tokenSize": [
      { "size": 0.5, "label": "Tiny" },
      { "size": 1, "label": "Medium" },
      { "size": 2, "label": "Large" },
      { "size": 3, "label": "Huge" }
    ]
  }
}

What the compiler does

  1. Reads ruleset.config.json.

  2. Walks the config and replaces every { "file": "..." } with the referenced file's contents.

  3. For tab entries, the file becomes the tab's layout.

  4. For rollTypes entries, the file becomes that roll type's handleResult.

  5. Fills in sensible defaults for any record fields you omitted.

  6. Sends the final payload to POST /rulesets (new) or PATCH /rulesets/:id (update).


Understanding Record Data

Once you've got a ruleset uploaded and working, the next step is usually seeding it with records — abilities, items, spells, NPCs, etc. Rulesets define the schema and behavior, but the actual records live in the Realm VTT database and are created in-app (or via the API / using custom importers).

It's worth knowing how a record is structured, because your HTML tabs and roll-handlers read and write these exact fields. Every field you bind to in HTML (field="data.level", field="data.cost", etc.) maps directly to a property on the record document.

Anatomy of a record

Here's an example of the Fighter class record from the 5e 2024 SRD. It's a good example because it shows almost everything you'll run into: top-level system fields, module/sharing metadata, level-scaled data fields, and a nested list of embedded sub-records (the class features), some of which contain their own embedded modifiers.

{
  "_id": { "$oid": "1234" },
  "name": "Fighter",
  "recordType": "class",
  "identified": true,
  "category": "5e 2024 SRD - Player Options",
  "portrait": "",
  "unidentifiedName": "Fighter",
  "campaignId": "12344",
  "moduleId": "123456",
  "sourceId": "1234567",
  "shared": true,
  "locked": true,
  "createdAt": 1757003134406,
  "data": {
    "primaryAbility": "Strength or Dexterity",
    "hitDie": "d10",
    "saveProficiencies": ["strength", "constitution"],
    "skillProficiencies": "Choose 2: Acrobatics, Animal Handling, Athletics, History, Insight, Intimidation, Persuasion, Perception, or Survival",
    "toolProficiencies": "",
    "weaponProficiencies": "Simple and Martial weapons",
    "armorTraining": "Light, Medium, and Heavy armor and Shields",
    "startingEquipment": "Choose A, B, or C: (A) Chain Mail, Greatsword, Flail, 8 Javelins, Dungeoneer's Pack, and 4 GP; (B) Studded Leather Armor, Scimitar, Shortsword, Longbow, 20 Arrows, Quiver, Dungeoneer's Pack, and 11 GP; or (C) 155 GP",
    "asMulticlass": null,
    "Description": "<p>As a Fighter, you gain the following class features when you reach the specified Fighter levels…</p>",
    "weaponProficienciesMulti": "Martial weapons",
    "armorTrainingMulti": "Light and Medium armor and Shields",
    "multiclassSpellcaster": "Third of Levels (round down) if Subclass",
    "multiclassSpellcasterSubclass": "Eldritch Knight",
    "casterType": "Spellcasting",
    "requiredSubclass": "Eldritch Knight",
    "spellcastingAbility": "intelligence",

    "level3spells": 3,
    "level4spells": 4,
    "level5spells": 4,
    "// …": "one entry per level up to level20spells",

    "level3spell1": 2,
    "level7spell2": 2,
    "level13spell3": 2,
    "level19spell4": 1,
    "// …": "slots-per-spell-level per class-level",

    "feature_list": [
      {
        "_id": "07cc36f5-b67c-474d-aa54-e02de54a7a1a",
        "name": "Fighting Style",
        "unidentifiedName": "Feature",
        "recordType": "records",
        "identified": true,
        "data": {
          "level": 1,
          "description": "<p>You have honed your martial prowess and gain a Fighting Style feat of your choice…</p>"
        }
      },
      {
        "_id": "0f904295-b5a5-4ffb-ba0c-4df907d44b17",
        "name": "Second Wind",
        "recordType": "records",
        "identified": true,
        "data": {
          "level": 1,
          "description": "<p>You have a limited well of physical and mental stamina…</p>",
          "ability": "{\"_id\":\"68b9bd7d228b6eaed7955645\",\"name\":\"Second Wind\"}",
          "abilityGroupName": "Second Wind",
          "maxDailyUses": 2,
          "restoreOn": "shortrestonce",
          "value": "1d10"
        }
      },
      {
        "_id": "3224bc2d-0eda-408a-852f-7224b1407559",
        "name": "Indomitable",
        "recordType": "records",
        "identified": true,
        "data": {
          "level": 9,
          "description": "<p>If you fail a saving throw, you can reroll it with a bonus equal to your Fighter level…</p>",
          "ability": "{\"_id\":\"68b9bd7d228b6eaed795561d\",\"name\":\"Indomitable\"}",
          "abilityGroupName": "Indomitable",
          "maxDailyUses": 1,
          "restoreOn": "longrest",
          "modifiers": [
            {
              "_id": "c5488de6-e83c-445d-bca8-2485aa6500d7",
              "name": "New Modifier",
              "recordType": "records",
              "identified": true,
              "data": {
                "type": "saveBonus",
                "valueType": "string",
                "value": "Fighter Level"
              }
            }
          ]
        }
      },
      {
        "_id": "d94126d9-5910-47a6-8ed6-8aa45e3b3076",
        "name": "Tactical Master",
        "recordType": "records",
        "identified": true,
        "data": {
          "level": 9,
          "description": "<p>When you attack with a weapon whose mastery property you can use…</p>",
          "modifiers": [
            {
              "_id": "d07f2dd1-…",
              "data": {
                "type": "weaponMastery",
                "valueType": "string",
                "value": "Push"
              }
            },
            {
              "_id": "cbb1636f-…",
              "data": {
                "type": "weaponMastery",
                "valueType": "string",
                "value": "Sap"
              }
            },
            {
              "_id": "484b4745-…",
              "data": {
                "type": "weaponMastery",
                "valueType": "string",
                "value": "Slow"
              }
            }
          ]
        }
      }
    ]
  }
}

The real record contains ~20 features in feature_list and spell tables for every class level — the snippet above is trimmed for readability. // … comments mark where entries were omitted (they are not valid JSON — just annotations for this doc).

Top-level fields

These are system fields that every record has, regardless of recordType:

Field

Description

_id

MongoDB ObjectId — unique per record

name

The display name

recordType

Which record type this is (matches a type in ruleset.config.json — e.g. class, abilities, items, characters)

campaignId

The campaign (or compendium) this record belongs to

identified

If false, players see unidentifiedName instead of name

unidentifiedName

Alternate name shown when not identified

category

Optional grouping label (e.g. the source book — "5e 2024 SRD - Player Options")

portrait

Image path shown on the record sheet and token

locked

When true, the record is read-only in the UI

shared

When true, the record is shared out of a module/compendium

moduleId

The module this record belongs to (when shared from a module)

sourceId

The original record this one was copied from (when imported/cloned)

createdAt

Unix millisecond timestamp

fields

Optional per-field UI overrides — see below

data

Everything else — the ruleset-defined schema

fields — UI overrides (optional)

The Fighter record above doesn't use a fields object, because every field in the class sheet applies. But many records do, and it's worth knowing what it's for. fields lets you control UI state per field without touching the underlying value — most commonly hiding fields that don't apply to a particular record:

"fields": {
  "archetype": { "hidden": true },
  "tradition": { "hidden": true }
}

The keys match the field="..." attributes on components in your HTML tabs. You can also drive hidden dynamically from within a rollhandler or onchange handler via api.setValues({ "fields.archetype.hidden": false }).

data — the ruleset-defined schema

Everything inside data is defined by your ruleset's HTML tabs. If your class tab has <numberfield field="data.level3spells">, then the record will have a data.level3spells field. There is no separate schema file — the tabs are the schema.

Looking at the Fighter example, the fields inside data fall into a few broad categories:

  • Simple strings and dropdowns — primaryAbility, hitDie, casterType, spellcastingAbility, requiredSubclass. Bound to <stringfield> / <dropdown>.

  • Arrays of strings — saveProficiencies: ["strength", "constitution"]. Bound to a multiselect dropdown.

  • Rich-text HTML — Description. Bound to <richtextfield>. Capitalized here purely because that's what the ruleset author named the field.

  • Level-scaled fields — level3spells, level4spells, …, level20spells, plus a per-spell-level matrix (level3spell1, level7spell2, level13spell3, level19spell4). This is a common ruleset-author pattern: rather than store a single "progression table", each cell of the class table is its own field. The HTML tab binds each cell to a numberfield, and the rollhandler looks up data[\level${n}spell] at runtime.

  • Embedded list fields — feature_list is a list of sub-records. This is how in-record lists (driven by list records like the ability_list / feature_list in your ruleset.config.json) serialize to the database.

These fields exist because the ruleset's HTML defines them. If you add a new field to your HTML it will appear on new records once it has been edited within the VTT. If you rename one, existing records will still have the old key in data until they're migrated (usually done using an onload function of a visible field, but it is best to avoid this whenever possible.)

Embedded records inside list fields

When a record contains a list — like the Fighter's feature_list — each entry is itself a miniature record with the usual envelope: _id, name, recordType, identified, and its own data object. The data inside each feature is schema-driven by the corresponding list tab's HTML.

These nested records can themselves contain list fields. In the snippet above, the Indomitable and Tactical Master features each carry a modifiers array — another embedded list, where each entry has its own _id and data.type / data.value — so roll-handlers can walk through features, pull out modifiers, and apply them (e.g. adding "Fighter Level" as a saveBonus).

The nesting depth is essentially unlimited — a feature can contain modifiers, a modifier can contain choice sets, etc. The rule is always the same: every embedded record has the same top-level envelope shape, and its data is defined by whatever HTML tab renders it.

Dropdowns with queries — JSON-stringified record references

When a dropdown is configured to pull its options from a record query (e.g. "pick an ability", "pick an effect to apply on save-fail"), Realm VTT stores the selected value(s) as JSON strings containing just the _id and name of the referenced record — not as plain objects.

Single-select — the field is a string holding one stringified object:

"ability": "{\"_id\":\"68b9bd7d228b6eaed7955645\",\"name\":\"Second Wind\"}"

Multi-select — the field is an array of strings, each one a stringified object:

"effects": [
  "{\"_id\":\"698d3eae4e9a13342d1497ac\",\"name\":\"Prone\"}",
  "{\"_id\":\"698d3eae4e9a13342d1497ad\",\"name\":\"Restrained\"}"
]

The stored payload intentionally only carries _id and name — it's a pointer, not a cached copy. To get the full record, look it up by _id:

const raw = api.getValue("data.ability");     // '{"_id":"…","name":"Second Wind"}'
const { _id, name } = JSON.parse(raw);
api.getRecord("abilities", _id, (rec) => {
  // rec is the full, fresh record
});

To write one of these fields programmatically, you must stringify the object yourself — the UI and rollhandlers expect a string, not an object:

api.setValues({
  "data.ability": JSON.stringify({ _id: record._id, name: record.name }),
});

The same applies to the multi-select case (data.effects, etc.) — each entry in the array has to be pre-stringified. This trips up almost everyone the first time: if you store the raw object, the dropdown will render blank because it's looking for a JSON string to parse.

Paths and nested data

When reading or writing via the API (api.getValue, api.setValues, api.addValue, etc.), paths use dot notation all the way down — including into arrays and nested records:

api.getValue("data.hitDie"); // "d10"
api.getValue("data.saveProficiencies.0"); // "strength"
api.getValue("data.feature_list.0.data.level"); // 1
api.getValue("data.feature_list.2.data.modifiers.0.data.value"); // "Fighter Level"
api.setValues({ "data.level3spells": 3 });

Note: bracket notation (data.saveProficiencies[0]) is not supported. Always use dot notation for array indices — this applies equally to api.setValues, api.setValuesOnRecord, and all path-based APIs.

It is usually best to use setValues with the full list of values you intend to set, then, if you need to set more after, do it in the callback of setValues. This is because frequent calls to setValue or setValues can create race conditions and unexpected things can happen as a result.

Key takeaways

  • The ruleset defines structure and behavior; records are data.

  • Top-level fields (_id, name, recordType, campaignId, identified, portrait, locked, shared, moduleId, sourceId, data) are the same on every record.

  • Everything record-specific lives under data and is shaped by your HTML tabs.

  • List fields (like feature_list) store arrays of nested records, each with their own envelope and data. Nesting can go many levels deep.

  • fields is optional and is for UI overrides only — it does not change the underlying values.

  • Use dot notation (including for array indices) when addressing any path.

Understanding this structure is what lets you write roll-handlers or build importers (if you wish to work with the API directly).

Using the Record API Directly

Once you understand the record shape, you can go a step further and read, create, or update records via the Realm VTT HTTP API directly. This is how bulk importers, external tooling, and integrations work — anything from scraping a source book into records to syncing an external database into a campaign.

Full reference: the Realm VTT API is documented as a Swagger/OpenAPI spec at play.realmvtt.com/api-docs. Every endpoint, query parameter, and response shape lives there. The examples below show the common patterns.

Note: Most endpoints require a campaignId. You cannot edit or alter data in a campaign that you are not the owner of or lack permissions for.

Auth — get a bearer token

All endpoints require a JWT bearer token. You get one by posting to /authentication:

const res = await fetch("https://utilities.realmvtt.com/authentication", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    strategy: "local",
    email: "[email protected]",
    password: "your-password",
  }),
});
const { accessToken } = await res.json();

Every subsequent request sends Authorization: Bearer <accessToken> alongside Content-Type: application/json.

const headers = {
  "Content-Type": "application/json",
  Authorization: `Bearer ${accessToken}`,
};

Resolve a campaignId

Almost every record is scoped to a campaign, so you'll need a campaignId. If you have the campaign's invite code (the short code shared with players), you can resolve it:

const url = `https://utilities.realmvtt.com/campaigns?inviteCode=${encodeURIComponent(inviteCode)}`;
const res = await fetch(url, { headers });
const { data } = await res.json();
const campaignId = data[0]?._id;

You can also fetch the list of campaigns you own/belong to by calling /campaigns without a query and picking the right one.

Record endpoints

Most record types live under a single generic endpoint:

Record type

Endpoint

Anything generic (abilities, items, spells, classes, …)

/records

NPCs

/npcs

Tables

/tables

For the generic endpoint, you pass recordType as a query param (for reads) or in the body (for writes). /npcs and /tables take the payload directly — they don't want recordType in the body.

Find a record by name

const params = new URLSearchParams({
  recordType: "abilities",
  campaignId,
  name: "Trampling Charge",
});
const res = await fetch(`https://utilities.realmvtt.com/records?${params}`, { headers });
const { data } = await res.json();
const record = data[0] ?? null;

For NPCs, drop recordType and hit /npcs instead:

const params = new URLSearchParams({ campaignId, name: "Orc Warlord" });
const res = await fetch(`https://utilities.realmvtt.com/npcs?${params}`, { headers });

Free-text search across a record type uses $search:

const params = new URLSearchParams({ recordType: "items", campaignId, $search: "longsword" });
const res = await fetch(`https://utilities.realmvtt.com/records?${params}`, { headers });

Fetch a record by ID

const res = await fetch(`https://utilities.realmvtt.com/records/${recordId}`, { headers });
const record = await res.json();

Create a record

POST /records with the full record envelope in the body. At minimum you need name, recordType, campaignId, and a data object shaped to your ruleset's schema:

const payload = {
  name: "Trampling Charge",
  recordType: "abilities",
  campaignId,
  identified: true,
  category: "Sourcebook Name Here",
  portrait: "/icons/fantasy/environment/people/charge.webp",
  data: {
    level: 1,
    type: "Culture Ability",
    actionType: "Other",
    isSave: true,
    savingThrows: ["dexterity"],
    damageTypes: ["bludgeoning"],
    cost: 1,
    description: "<p>Caravanners have learned to bowl down obstacles…</p>",
  },
};

const res = await fetch("https://utilities.realmvtt.com/records", {
  method: "POST",
  headers,
  body: JSON.stringify(payload),
});
const created = await res.json();

For NPCs and tables, hit the dedicated endpoint and omit recordType from the body:

await fetch("https://utilities.realmvtt.com/npcs", {
  method: "POST",
  headers,
  body: JSON.stringify({ name: "Orc Warlord", campaignId, data: { /* … */ } }),
});

Update a record

PATCH /records/:id with a partial body — only the fields you want to change:

await fetch(`https://utilities.realmvtt.com/records/${recordId}`, {
  method: "PATCH",
  headers,
  body: JSON.stringify({
    "data.cost": 2,
    "data.damageTypes": ["bludgeoning", "force"],
  }),
});

Dot-notation paths work in PATCH bodies the same way they do in the in-app API — you can target deeply nested fields (including array indices) without sending the whole record back.

Typical import pattern — upsert by name

Most importers follow this shape: for each source entry, look up by name, then either PATCH the existing record or POST a new one.

async function upsertRecord(recordType, payload) {
  const params = new URLSearchParams({
    recordType,
    campaignId: payload.campaignId,
    name: payload.name,
  });
  const found = await fetch(`https://utilities.realmvtt.com/records?${params}`, { headers })
    .then((r) => r.json());

  if (found.data?.length) {
    const id = found.data[0]._id;
    return fetch(`https://utilities.realmvtt.com/records/${id}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify(payload),
    }).then((r) => r.json());
  }

  return fetch("https://utilities.realmvtt.com/records", {
    method: "POST",
    headers,
    body: JSON.stringify(payload),
  }).then((r) => r.json());
}

This is idempotent — re-running the importer updates existing records in place rather than duplicating them.

Other endpoints worth knowing

These all live under the same base URL and use the same bearer auth. See the Swagger docs for full shapes.

Endpoint

Purpose

GET/POST/PATCH /effects

Status effects (e.g. Prone, Stunned) referenced from records

GET/POST/PATCH /journals

Journal books

GET/POST/PATCH/DELETE /journal-pages

Individual journal pages (?journalId=…&$sort[pageNumber]=1&$limit=500)

POST /upload

multipart/form-data upload — returns a relative path usable as portrait or in HTML

GET/POST /images

Image library (?campaignId=…&$search=… or &$urlSearch=…)

GET /realmvtt-icons

Built-in icon list

Query operators

Realm VTT's API supports a rich query string syntax (inherited from Feathers/MongoDB):

  • $search=foo — free-text search

  • $sort[field]=1 / $sort[field]=-1 — sort ascending/descending

  • $limit=100 / $skip=200 — pagination

  • $in, $nin, $gt, $gte, $lt, $lte, $ne — comparison operators (e.g. data.level[$gte]=5)

Combine these with recordType and campaignId to slice up records however you like. The full grammar is in the Swagger docs.

Rate limits and etiquette

  • Run imports serially or with low concurrency

  • Cache campaignId resolution and the bearer token between requests.

  • When doing bulk upserts, batch read queries by fetching many records in one call (using $in on _id or name) rather than one request per entry.

Wiki/Developers/Advanced Ruleset Creation
Documentation
About
Looking for Games - Free and Paid Games on Realm VTT
Developers
Ruleset Editor and API
Advanced Ruleset Creation
Advanced Ruleset Sheet CSS
Help
Troubleshooting
Tutorials
Getting Started
New User Tour
Modules
Creating and Editing Scenes
VTT Controls
D&D 5th Edition (2024) Ruleset User Guide
Interactive World Maps: A Guide to Atlas Mode
DevelopersUpdated 4/23/2026 · 20 min read

Advanced Ruleset Creation

Was this helpful?