Data Contracts

Every notification can define a data contract using JSON Schema. The schema powers LSP completions, nullable-access warnings, codegen, and sample data validation.

Here is a minimal example:

# notifications/welcome/data.schema.yaml
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user]
properties:
  user:
    type: object
    required: [first_name]
    properties:
      first_name: { type: string }

File

data.schema.yaml -- placed inside the notification directory. One schema per notification, shared across all channels.

Format

Standard JSON Schema (draft 2020-12) written in YAML for readability:

$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user, action_url]
properties:
  user:
    type: object
    required: [first_name, email]
    properties:
      first_name: { type: string }
      last_name: { type: string }
      email: { type: string, format: email }
      tier: { type: string, enum: [free, pro, enterprise] }
  action_url: { type: string, format: uri }
  unsubscribe_url: { type: string, format: uri }

Required vs. optional fields

Fields listed in required are guaranteed non-null. Fields not in required are nullable -- they can be absent at runtime. The linter warns if you use them without a guard (:::if or | default).

The linter uses this distinction for null safety:

# 'last_name' is not in required -- using it without a guard is a warning
warning: 'last_name' may be null — wrap in :::if guard or use | default "..."
  --> email.md:8:5
   |
 8 |     Hello {{user.last_name}}
   |           ^^^^^^^^^^^^^^^^^^
   = rule: template/nullable-access

Fix with an :::if guard:

::: if user.last_name
Hello {{user.last_name}}
:::else
Hello {{user.first_name}}
:::

Or with a | default pipe:

Hello {{user.last_name | default user.first_name}}

Nullable fields

Express nullable fields using oneOf:

tracking_id:
  oneOf: [{ type: string }, { type: "null" }]

This tells the linter that tracking_id can be null, enabling nullable-access warnings.

Array types

items:
  type: array
  items:
    type: object
    required: [name, qty, price]
    properties:
      name: { type: string }
      qty: { type: integer }
      price: { type: number }

Arrays are used with :::each loops:

::: each order.items as item
- {{item.name}} x {{item.qty}}
:::

Description fields

Add description to schema fields -- the LSP surfaces these as hover documentation in the editor:

properties:
  tier:
    type: string
    enum: [free, pro, enterprise]
    description: "User's subscription tier, determines feature access"

What the schema enables

Feature Without schema With schema
LSP completions No variable suggestions Full autocompletion with types
Null safety No warnings Warns on nullable access outside :::if
Codegen Generates Record<string, any> Generates typed interfaces/structs
Sample validation No validation Samples validated against schema
Preview Works (no validation) Works + validation errors shown

Why JSON Schema?

  • Developers already know it (OpenAPI, VS Code settings, Adaptive Cards all use it)
  • Every language has validators: ajv (JS), jsonschema (Python), gojsonschema (Go)
  • Can be generated from Prisma schemas, TypeScript types, OpenAPI specs
  • VS Code validates it natively with the JSON Schema meta-schema

Sample data

Sample data lives in a separate file (data.samples.yaml) and must validate against the schema:

samples:
  - name: "New user"
    data:
      user:
        first_name: "Gaurav"
        email: "gaurav@example.com"
        tier: "free"
      action_url: "https://app.example.com/start"
      unsubscribe_url: "https://app.example.com/unsub"
 
  - name: "Pro user"
    data:
      user:
        first_name: "Priya"
        last_name: "Singh"
        email: "priya@example.com"
        tier: "pro"
      action_url: "https://app.example.com/start"
      unsubscribe_url: "https://app.example.com/unsub"

Each sample has a name (used in CLI --sample flag, preview dropdown, and test references) and a data object.

Project config (norq.config.yaml)

Beyond data schemas, the project config file controls providers, routing, and codegen.

Providers

A provider is a delivery service (e.g., Resend for email, Twilio for SMS). Declare provider credentials under providers. Environment variables use ${VAR} syntax and are substituted at load time.

providers:
  resend:
    config:
      api_key: ${RESEND_API_KEY}
  twilio:
    config:
      account_sid: ${TWILIO_SID}
      auth_token: ${TWILIO_TOKEN}
      from: "+1234567890"
  console:
    config: {}

The console provider prints payloads to stdout -- useful for local development and CI.

Routing

Routing maps each channel to the provider that delivers it. Only channels with a routing entry are deliverable via norq send.

routing:
  email: resend
  sms: twilio
  slack: console

Code generation

Generate type-safe SDK bindings from your data schemas. Codegen reads all data.schema.yaml files and generates one file per notification with short leaf names. The output directory mirrors the notification structure.

codegen:
  - lang: typescript
    out: ./src/generated/norq
  - lang: python
    out: ./generated/norq

Supported languages: typescript, python, go, java, ruby.

norq codegen                           # use config targets
norq codegen --lang python --out ./generated/norq
norq codegen --check                   # CI: verify files are up to date

Output structure (Python example):

generated/norq/
  __init__.py                          # re-exports all types
  transactional/
    welcome.py                         # WelcomeData, WelcomeChannel
    account/
      security_alert.py                # SecurityAlertData
  system/
    password_reset.py                  # PasswordResetData

Top-level types are public (WelcomeData). Nested types are private (_WelcomeDataUser in Python/TypeScript/Ruby, unexported in Go, package-private in Java). Import from the module path:

from generated.norq.transactional.welcome import WelcomeData
import { WelcomeData } from "./generated/norq/transactional/welcome";