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
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.
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 parameter | Type | Default | Description |
|---|
cursor | string | — | Pagination cursor from the previous response’s next or previous cursor |
pageSize | integer | 15 | Maximum number of schema versions to return per page |
sort | string | created_at | Field to sort by (currently only created_at is supported) |
order | string | desc | Sort 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:
| Mode | Behavior |
|---|
audit (default) | Allow the transaction (validation failures are logged for review) |
strict | Reject the transaction with an error |
Behavior summary
| Scenario | Strict | Audit |
|---|
| 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:
| Property | Required | Description |
|---|
script | Yes | The Numscript program to execute |
description | No | Human-readable description of what the template does |
runtime | No | Which 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:
| Property | Required | Description |
|---|
resource | Yes | The resource type to query: transactions, accounts, logs, or volumes |
body | No | A filter expression using the same syntax as filtering queries. Variables are referenced with :varName: syntax. |
vars | No | Variable declarations with types. Each variable specifies a type (e.g., account, string) and an optional default value. |
params | No | Default pagination and sorting parameters (pageSize, sort, endTime, startTime, expand) |
description | No | Human-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:
| Field | Description |
|---|
vars | Values for the declared variables in the query template |
params | Override the template’s default pagination and sorting |
cursor | Pagination 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"
}
}
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:savings — savings not defined in chart
payments:xyz — payments not in chart