Skip to main content
A Ledger Schema lets you define which account addresses are valid in your ledger, store reusable transaction and query templates, and automatically validate transactions against those rules.

Why use a Ledger Schema?

By default, the ledger accepts any account address. A schema adds structure:
  • Catch errors early: Reject typos like users:alcie before they create orphaned accounts
  • Enforce naming conventions: Require user IDs to match a specific format
  • Auto-assign metadata: New accounts automatically get default metadata values
  • Audit trail: Every transaction records which schema version validated it
For guidance on designing your account hierarchy, see Chart of Accounts.

Schema structure

A schema consists of three required fields:
  • chart: Defines valid account patterns
  • transactions: Defines reusable transaction templates (can be {}). See Transaction templates.
  • queries: Defines reusable query templates (can be {}). See Query templates.
{
  "chart": {
    "world": {},
    "banks": {
      "$iban": {
        ".pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$",
        ".self": {},
        "main": {},
        "fees": {}
      }
    },
    "users": {
      "$userId": {
        ".metadata": {
          "type": { "default": "customer" }
        }
      }
    }
  },
  "transactions": {},
  "queries": {}
}

Defining your chart

The chart uses a nested JSON structure with special prefixes to distinguish between account segments and properties.

Fixed segments

Fixed segments are literal account path components. In the example above, world, banks, users, main, and fees are fixed segments.
{
  "banks": {
    "main": {},
    "fees": {}
  }
}
This defines valid accounts: banks:main and banks:fees.

Variable segments

Variable segments start with $ and match any value. The text after $ is the variable name (e.g., $userId, $orderId), which helps document what the segment represents.
{
  "users": {
    "$userId": {}
  }
}
This matches any account like users:123, users:alice, or users:order-456.

Pattern validation

Add a .pattern property to validate variable segments against a regular expression:
{
  "banks": {
    "$iban": {
      ".pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$"
    }
  }
}
This only matches accounts where the IBAN segment is valid, like banks:GB82WEST12345698765432. An account like banks:abc123 would be rejected because it doesn’t match the IBAN format.

Leaf vs non-leaf accounts

By default, a segment without children is a valid account (leaf). To define a segment that:
  • Has children AND
  • Is itself a valid account
Use the .self property:
{
  "orders": {
    "$orderId": {
      ".self": {},
      "pending": {},
      "completed": {}
    }
  }
}
This makes all of these valid:
  • orders:123 (the order itself)
  • orders:123:pending (pending state)
  • orders:123:completed (completed state)
Without .self, only orders:123:pending and orders:123:completed would be valid.

Default metadata

Define default metadata values for accounts matching a pattern:
{
  "users": {
    "$userId": {
      ".metadata": {
        "type": { "default": "customer" },
        "tier": { "default": "standard" }
      }
    }
  }
}
When an account like users:alice is created, it automatically receives {"type": "customer", "tier": "standard"}.
Defaults only apply when an account is first created. They never overwrite existing metadata.

Validation rules

When defining your chart, keep these constraints in mind:
  • Segment names can only contain letters, numbers, underscores, and hyphens
  • Root segments must be fixed—you cannot start your chart with a variable ($userId) or property (.pattern)
  • One variable per level — each level in your chart can have at most one variable segment
  • Patterns only on variables — you cannot add .pattern to a fixed segment
For example, this is invalid because it has two variable segments at the same level:
{
  "users": {
    "$userId": {},
    "$username": {}
  }
}

Managing schemas

Create a schema

Insert a schema with a version identifier:
curl -X POST "http://localhost:3068/v2/my-ledger/schemas/v1.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "chart": {
      "world": {},
      "users": {
        "$userId": {}
      },
      "merchants": {
        "$merchantId": {
          "revenue": {},
          "payouts": {}
        }
      }
    },
    "transactions": {}
  }'
Schemas are immutable—once created, a version cannot be modified. Create a new version to evolve your account structure.
Use semantic versioning (e.g., v1.0.0, v1.1.0, v2.0.0) to communicate breaking vs non-breaking changes.

List schemas

Retrieve all schema versions for a ledger. The endpoint supports cursor-based pagination and sorting.
Query parameterTypeDefaultDescription
cursorstringPagination cursor from the previous response’s next or previous cursor
pageSizeinteger15Maximum number of schema versions to return per page
sortstringcreated_atField to sort by (currently only created_at is supported)
orderstringdescSort order: asc or desc
# First page (newest first)
curl "http://localhost:3068/v2/my-ledger/schemas"

# With pagination and sort order
curl "http://localhost:3068/v2/my-ledger/schemas?pageSize=10&order=asc"
The response includes a next cursor when more results are available; use it as the cursor query parameter to fetch the next page.

Get a specific schema

Retrieve a schema by version. The response returns the full schema JSON (chart, transactions, and queries) for that version.
curl "http://localhost:3068/v2/my-ledger/schemas/v1.0.0"

Creating transactions with a schema

Once a schema exists on a ledger, you must pass the schemaVersion query parameter when creating transactions:
curl -X POST "http://localhost:3068/v2/my-ledger/transactions?schemaVersion=v1.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "postings": [{
      "source": "world",
      "destination": "users:alice",
      "amount": 1000,
      "asset": "USD/2"
    }]
  }'
The ledger validates that all accounts (source and destination) match the chart before committing. The schema version is recorded in the transaction log for audit purposes. When reverting a transaction, pass the same schemaVersion query parameter so the revert is validated against the schema and the version is recorded in the log:
curl -X POST "http://localhost:3068/v2/my-ledger/transactions/TX_ID/revert?schemaVersion=v1.0.0"

Enforcement modes

Control what happens when validation fails:
ModeBehavior
audit (default)Allow the transaction (validation failures are logged for review)
strictReject the transaction with an error

Behavior summary

ScenarioStrictAudit
Schema specified, validation passes✓ Commits✓ Commits
Schema specified, validation fails✗ Rejects⚠ Warns, commits
Schema specified but doesn’t exist✗ Rejects✗ Rejects
No schema specified, but schemas exist in ledger✗ Rejects⚠ Warns, commits
No schema specified, no schemas exist✓ Commits✓ Commits
When a schema is specified but not found, it’s always an error regardless of mode. The enforcement mode only affects validation failures.

Configuration

Set the mode when starting the server:
ledger serve --schema-enforcement-mode=strict
Or via environment variable:
export SCHEMA_ENFORCEMENT_MODE=strict

Transaction templates

Transaction templates let you define reusable Numscript programs in your schema. Instead of sending raw Numscript with each request, your application references a template by name and provides variable values.

Defining templates

Templates are defined in the transactions field alongside the chart:
{
  "chart": {
    "world": {},
    "users": {
      "$userId": {
        "wallet": {}
      }
    }
  },
  "transactions": {
    "DEPOSIT": {
      "description": "Fund a user wallet",
      "script": "vars {\n  account $user\n}\nsend [COIN 10] (\n  source = @world\n  destination = $user\n)"
    }
  }
}
Each template has the following properties:
PropertyRequiredDescription
scriptYesThe Numscript program to execute
descriptionNoHuman-readable description of what the template does
runtimeNoWhich Numscript interpreter to use: machine (default) or experimental-interpreter

Executing templates

To execute a template, pass the template name and variables in the script field:
curl -X POST "http://localhost:3068/v2/my-ledger/transactions?schemaVersion=v1.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "script": {
      "template": "DEPOSIT",
      "vars": {
        "user": "users:alice:wallet"
      }
    },
    "metadata": {
      "reference": "DEP-2024-001"
    }
  }'
Variables can be passed as:
  • Strings: "user": "users:alice:wallet"
  • Monetary values: "amount": "USD/2 5000" or "amount": { "asset": "USD/2", "amount": 5000 }
The ledger executes your template with the provided values. The template name is recorded on the transaction and returned in the response, so you can trace which template was used for each transaction.
When a schema exists with templates, transactions must reference a template. In strict mode, transactions without a template are rejected. In audit mode, a warning is logged but the transaction is allowed.

Query templates

Query templates let you define reusable, parameterized queries in your schema. Instead of constructing filter expressions in your application code, you define named queries that target a specific resource type (transactions, accounts, logs, or volumes) and accept typed variables at runtime.

Defining query templates

Query templates are defined in the queries field of your schema:
{
  "chart": { "..." : {} },
  "transactions": {},
  "queries": {
    "RECENT_USER_TRANSACTIONS": {
      "description": "List transactions for a specific user",
      "resource": "transactions",
      "vars": {
        "userId": { "type": "account" }
      },
      "body": {
        "$match": { "destination": ":userId:" }
      },
      "params": {
        "pageSize": 25,
        "sort": "id:desc"
      }
    }
  }
}
Each query template has the following properties:
PropertyRequiredDescription
resourceYesThe resource type to query: transactions, accounts, logs, or volumes
bodyNoA filter expression using the same syntax as filtering queries. Variables are referenced with :varName: syntax.
varsNoVariable declarations with types. Each variable specifies a type (e.g., account, string) and an optional default value.
paramsNoDefault pagination and sorting parameters (pageSize, sort, endTime, startTime, expand)
descriptionNoHuman-readable description of what the query does
For volumes resources, params also supports groupBy (integer) and insertionDate (boolean).

Running a query template

Run a query template with the POST /v2/{ledger}/queries/{id}/run endpoint:
curl -X POST "http://localhost:3068/v2/my-ledger/queries/RECENT_USER_TRANSACTIONS/run?schemaVersion=v1.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "vars": {
      "userId": "users:alice"
    }
  }'
The schemaVersion query parameter is required and tells the ledger which schema version contains the query template. The request body accepts:
FieldDescription
varsValues for the declared variables in the query template
paramsOverride the template’s default pagination and sorting
cursorPagination cursor for fetching subsequent pages
The response is a standard cursor response with an additional resource field indicating the type of results returned:
{
  "resource": "transactions",
  "cursor": {
    "hasMore": true,
    "next": "...",
    "pageSize": 25,
    "data": [...]
  }
}

Variable substitution

Variables declared in vars are substituted into the filter body at runtime. Reference variables using :varName: syntax in the filter expression:
{
  "vars": {
    "src": { "type": "account" },
    "dst": { "type": "account" }
  },
  "body": {
    "$and": [
      { "$match": { "source": ":src:" } },
      { "$match": { "destination": ":dst:" } }
    ]
  }
}
When running the query, pass the variable values:
{
  "vars": {
    "src": "world",
    "dst": "users:alice"
  }
}

Example: Payment platform

A complete schema for a payment platform:
{
  "chart": {
    "world": {},
    "platform": {
      ".self": {},
      "fees": {},
      "float": {}
    },
    "merchants": {
      "$merchantId": {
        ".pattern": "^mch_[a-zA-Z0-9]{16}$",
        ".self": {},
        ".metadata": {
          "type": { "default": "merchant" }
        },
        "pending": {},
        "available": {}
      }
    },
    "customers": {
      "$customerId": {
        ".pattern": "^cus_[a-zA-Z0-9]{16}$",
        ".metadata": {
          "type": { "default": "customer" }
        },
        "wallet": {}
      }
    },
    "orders": {
      "$orderId": {
        ".pattern": "^ord_[a-zA-Z0-9]{16}$",
        "capture": {},
        "refunds": {
          "$refundId": {
            ".pattern": "^ref_[a-zA-Z0-9]{16}$"
          }
        }
      }
    }
  },
  "transactions": {},
  "queries": {}
}
Valid accounts:
  • platform:fees
  • merchants:mch_abc123def456ghij:available
  • customers:cus_xyz789abc123defg:wallet
  • orders:ord_123abc456def789g:refunds:ref_abc123def456ghij
Rejected accounts:
  • merchants:acme — doesn’t match mch_ prefix pattern
  • customers:cus_abc:savingssavings not defined in chart
  • payments:xyzpayments not in chart