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: consoleCode 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/norqSupported 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 dateOutput 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 WelcomeDataimport { WelcomeData } from "./generated/norq/transactional/welcome";