_Docs/
Get StartedModulesPlatformDeployCookbookChangelogReference
_Stack
_Modules
  • Ledger
    • Quick Start
    • Core Concepts
      • Accounts
      • Transactions
      • Constraints
      • Source/destination
      • Designing a Chart of Accounts
    • Working with the Ledger
      • Assets & Currency conversion
      • Bi-temporality
      • Bulk processing
      • Filtering queries
      • Idempotency
      • Data isolation with buckets
      • From credit/debit to source/destination
      • Streaming to analytics systems
      • Ledger Schema
    • Advanced Topics
      • Architecting for scale
      • Events Publishers
      • Performance model
  • Numscript
  • Connectivity
  • WalletsEE
  • FlowsEE
  • ReconciliationEE
  1. Modules
  2. Ledger
  3. Working with the Ledger
  4. Ledger Schema
Ledger

Ledger Schema

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.

JSON
{
  "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.

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

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

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

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

JSON
{
  "users": {
    "$userId": {},
    "$username": {}
  }
}

Managing schemas#

Create a schema#

Insert a schema with a version identifier:

curl -X POST $FORMANCE_API_URL/api/ledger/v2/my-ledger/schemas/v1.0.0 \
  -H "Content-Type: application/json" \
  -d '{
    "chart": {
      "world": {},
      "users": {
        "$userId": {}
      },
      "merchants": {
        "$merchantId": {
          "revenue": {},
          "payouts": {}
        }
      }
    },
    "transactions": {}
  }'
POST/api/ledger/v2/my-ledger/schemas/v1.0.0

Go SDK equivalent:

Go
_, err := client.Ledger.V2.InsertSchema(ctx, operations.V2InsertSchemaRequest{
    Ledger:  "my-ledger",
    Version: "v1.0.0",
    V2SchemaData: components.V2SchemaData{
        Chart: map[string]any{
            "world": map[string]any{},
            "users": map[string]any{"$userId": map[string]any{}},
            "merchants": map[string]any{
                "$merchantId": map[string]any{
                    "revenue": map[string]any{},
                    "payouts": map[string]any{},
                },
            },
        },
        Transactions: map[string]any{},
    },
})

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
cursorstring—Pagination 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
curl -X GET $FORMANCE_API_URL/api/ledger/v2/my-ledger/schemas
GET/api/ledger/v2/my-ledger/schemas

With pagination and sort order:

curl -X GET $FORMANCE_API_URL/api/ledger/v2/my-ledger/schema?pageSize=10&order=asc
GET/api/ledger/v2/my-ledger/schema

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 -X GET $FORMANCE_API_URL/api/ledger/v2/my-ledger/schemas/v1.0.0
GET/api/ledger/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 $FORMANCE_API_URL/api/ledger/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"
      }
    ]
  }'
POST/api/ledger/v2/my-ledger/transactions

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 $FORMANCE_API_URL/api/ledger/v2/my-ledger/transactions/TX_ID/revert?schemaVersion=v1.0.0
POST/api/ledger/v2/my-ledger/transactions/TX_ID/revert

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#

ledger.schema-enforcement-mode
example: strictdefault: nonestring

Controls how strictly the ledger enforces its schema; set on both API and worker deployments via the SCHEMA_ENFORCEMENT_MODE environment variable.

This setting can be configured on Formance Cloud deployments by contacting your Formance Cloud support team.

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:

JSON
{
  "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 $FORMANCE_API_URL/api/ledger/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"
    }
  }'
POST/api/ledger/v2/my-ledger/transactions

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:

JSON
{
  "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 $FORMANCE_API_URL/api/ledger/v2/my-ledger/queries/RECENT_USER_TRANSACTIONS/run?schemaVersion=v1.0.0 \
  -H "Content-Type: application/json" \
  -d '{
    "vars": {
      "userId": "users:alice"
    }
  }'
POST/api/ledger/v2/my-ledger/queries/RECENT_USER_TRANSACTIONS/run

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:

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

JSON
{
  "vars": {
    "src": { "type": "account" },
    "dst": { "type": "account" }
  },
  "body": {
    "$and": [
      { "$match": { "source": ":src:" } },
      { "$match": { "destination": ":dst:" } }
    ]
  }
}

When running the query, pass the variable values:

JSON
{
  "vars": {
    "src": "world",
    "dst": "users:alice"
  }
}

Example: Payment platform#

A complete schema for a payment platform:

JSON
{
  "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
Streaming to analytics systemsArchitecting for scale
On This Page
  • Why use a Ledger Schema?
  • Schema structure
  • Defining your chart
  • Fixed segments
  • Variable segments
  • Pattern validation
  • Leaf vs non-leaf accounts
  • Default metadata
  • Validation rules
  • Managing schemas
  • Create a schema
  • List schemas
  • Get a specific schema
  • Creating transactions with a schema
  • Enforcement modes
  • Behavior summary
  • Configuration
  • Transaction templates
  • Defining templates
  • Executing templates
  • Query templates
  • Defining query templates
  • Running a query template
  • Variable substitution
  • Example: Payment platform