Skip to main content
A Ledger Schema lets you define which account addresses are valid in your ledger, store reusable transaction 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 two required fields:
  • chart: Defines valid account patterns
  • transactions: Defines reusable transaction templates (can be empty)
{
  "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": {}
}

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/schema/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:
curl "http://localhost:3068/v2/my-ledger/schema"

Get a specific schema

Retrieve a schema by version:
curl "http://localhost:3068/v2/my-ledger/schema/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.

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.
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.

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": {}
}
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