Skip to main content

Understanding the Schema System

Raytio uses JSON schemas to drive its dynamic form generation system (the "Wizard"). This page explains how the schema system works, why it is designed this way, and how its components fit together.

For the full property and tag specifications, see the Schema Reference.

How schemas become forms

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ JSON Schema │────▶│ Client Process │────▶│ HTML Form │
│ (S3 Bucket) │ │ (DynamicForm) │ │ (Wizard) │
└─────────────────┘ └──────────────────┘ └─────────────────┘


┌──────────────────┐
│ Group Rules │
│ (Tag Matching) │
└──────────────────┘


┌──────────────────┐
│ DynamicSection │
│ (Specialized UI)│
└──────────────────┘
  1. Schemas are JSON documents stored in S3, divided into system_schema (managed by Raytio) and tenant_schema (customer-specific).
  2. The client fetches and processes schemas at runtime — there is no build step or code generation.
  3. DynamicForm reads the schema's properties and converts each one into a form field.
  4. Group rules inspect field tags (e.g. group:address, group:date_picker) and route groups of fields to specialized rendering components called DynamicSections.
  5. Conditional logic shows or hides fields and entire pages based on what the user has entered so far.
  6. Validation is derived from standard JSON Schema properties (required, pattern, minimum, maximum), so the schema is both the form definition and the validation ruleset.

The key insight is that a single JSON document defines the data structure, the form layout, the validation rules, and the verification requirements. This avoids the duplication and drift that occurs when these concerns are defined separately.

Schema types and naming

Every schema name must match the pattern ^(p|u|s)s_[A-Za-z0-9_]+$. The prefix determines who owns the schema:

PrefixTypePurpose
ss_System SchemaManaged by Raytio, shared across all tenants. Used for standard document types like driver licenses and passports.
ps_Profile SchemaBelongs to a specific user's profile.
us_User SchemaCustom schemas defined by tenant administrators for their own onboarding flows.

This naming convention exists because the client needs to distinguish ownership at runtime — system schemas are fetched from a shared bucket, while tenant schemas come from a tenant-specific location.

Key concepts

TermWhat it is
SchemaA JSON document that defines a data type (e.g. ss_Person, ss_Driver_License). It describes both the shape of the data and how it should be rendered.
SchemaFieldA single property within a schema. Each field has a type, optional tags, and rendering hints.
TagsStrings attached to fields or schemas that modify rendering and behavior. Tags are the primary mechanism for customising how fields appear without writing code.
GroupA collection of fields that share a group:* tag prefix. Grouped fields are rendered together by a specialised DynamicSection.
DynamicSectionA React component registered with a GroupRule. When the form encounters a group of fields, it looks up the matching DynamicSection by tag and delegates rendering to it.
LookupAn external JSON file (hosted on S3 or an API) that provides dropdown options. Lookups allow option lists to be shared across schemas and updated independently.
WizardThe multi-page form flow that walks the user through data collection. Each page can contain one or more schemas.
WizardPageA single page in a wizard. It references schemas and can override their behavior (e.g. disabling verification for a particular step).
ProfileObject (PO)A stored instance of a schema containing the user's actual data. When a user fills in a form, the result is a ProfileObject.

The tag system

Tags are the central extension mechanism. Rather than hard-coding UI behavior for each field, the schema system uses tags to declaratively request specialised rendering.

Field-level tags control how individual fields render. For example, group:address causes address fields to be rendered with Google Places autocomplete, while group:date_picker:birth renders date fields as a unified date picker.

Schema-level tags control broader behavior. For example, action:verify tells the system that this schema's data should be sent to the verification API after submission.

Tags follow a hierarchical naming convention using colons as separators (e.g. group:date_picker:birth). The matching system uses priority-based rules — more specific matches take precedence over general ones.

Groups and DynamicSections

The grouping system is what gives schemas their power. Instead of every field rendering as a plain text input, fields with the same group:* tag are collected together and handed to a specialised React component.

For example, three separate integer fields tagged with group:date_picker:birth and date_component:day, date_component:month, date_component:year are rendered as a single date picker control. The underlying data model stays as three separate integers, but the user sees one cohesive UI element.

This design separates the data model from the UI — the schema defines what data is collected (three integers for a date), while the DynamicSection determines how it is presented (a date picker).

Built-in DynamicSections include address pickers (Google Places), image capture (with OCR), camera capture, name pickers, QR code flows, and integrations with external services like Stripe, Yodlee, and Akahu.

Conditional logic

Schemas support two mechanisms for conditional behavior:

Field-level conditions use a simple if property to show or hide a field based on another field's value. This covers the most common case — showing a "middle name" field only when the user checks "I have a middle name".

Schema-level allOf conditions use JSON Schema's standard if/then/else pattern for more complex scenarios — changing validation rules, modifying required fields, or altering verified fields based on user input.

The choice between the two comes down to scope: field-level if controls visibility of a single field, while allOf can modify multiple aspects of the schema simultaneously.

Verification

Schemas can declare that certain fields should be verified against external data sources (government databases, document verification services, etc.). This requires two things working together:

  1. The schema must have the action:verify tag
  2. The schema must list the fields to verify in verified_fields

Both are required — the tag tells the system that verification should happen, and the array tells it which fields to check. This two-part requirement is intentional: it prevents accidental verification calls and makes the verification scope explicit.

Lookups

Lookups solve the problem of maintaining dropdown option lists. Rather than embedding options directly in every schema that needs them (e.g. a list of countries), lookups point to a shared JSON file. This means:

  • Option lists can be updated without modifying schemas
  • The same list is reused across schemas, ensuring consistency
  • Large option lists don't bloat the schema document
  • Cascading lookups can filter options based on other field values (e.g. showing cities for a selected country)

The client decides how to render the options based on count: 7 or fewer options appear as radio buttons, more than 7 appear as a searchable dropdown.

Display configuration

The display object in a schema controls how ProfileObjects (saved data) appear in the UI — not how the form looks, but how the completed data is shown in lists, cards, and detail views.

The head_main and head_sub properties determine the primary and secondary text shown for each record. This is particularly important for foreign key resolution — when one schema references another via foreign_key_of, the referenced schema's head_main determines what is displayed instead of a raw ID.