Project Configuration
The norq.config.yaml file controls project-wide settings: notification directory, provider credentials, channel routing, brand path, and code generation targets. Visual identity (colors, typography, etc.) lives in a separate brand.yaml – see the Brand page.
Minimal config
# norq.config.yaml
notifications: ./notifications
providers:
console:
config: {}
routing:
email: consoleNotifications path
The notifications key points to the root directory containing your notification folders:
notifications: ./notificationsAll notification type folders (system/, transactional/, promotional/) and the _shared/ partials directory live under this path.
Providers
A provider is a delivery service – Resend for email, Twilio for SMS, etc. 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_ACCOUNT_SID}
api_key_sid: ${TWILIO_API_KEY_SID}
api_key_secret: ${TWILIO_API_KEY_SECRET}
from: "+1234567890"
console:
config: {}Built-in providers
Norq ships with ten built-in providers — email, SMS, push, the SuprSend hub, and a debug console. The recommended path is to configure them in norq.config.yaml under providers.<name>.config. Legacy NORQ_<NAME>_* environment variables work as a fallback when a provider’s config block is absent — norq provider list reports configured (config) vs configured (env) so you can tell which path applied.
| Provider | Channels | Config keys (providers.<name>.config) |
Env-var fallback |
|---|---|---|---|
| resend | api_key, from, from_name (optional), reply_to (optional, scalar or list) |
NORQ_RESEND_API_KEY, NORQ_RESEND_FROM_ADDRESS |
|
| sendgrid | api_key, from, from_name (optional), reply_to (optional, scalar or list) |
NORQ_SENDGRID_API_KEY, NORQ_SENDGRID_FROM_ADDRESS |
|
| twilio | sms | account_sid, api_key_sid, api_key_secret, from or messaging_service_sid |
NORQ_TWILIO_ACCOUNT_SID, NORQ_TWILIO_API_KEY_SID, NORQ_TWILIO_API_KEY_SECRET, NORQ_TWILIO_FROM |
| bird | sms | workspace_id, access_key, channel_id |
NORQ_BIRD_WORKSPACE_ID, NORQ_BIRD_ACCESS_KEY, NORQ_BIRD_CHANNEL_ID |
| suprsend | all channels + render | workspace_key, workspace_secret, base_url (optional) |
NORQ_SUPRSEND_WORKSPACE_KEY, NORQ_SUPRSEND_WORKSPACE_SECRET, NORQ_SUPRSEND_BASE_URL |
| console | all channels | (none — always available) | (none) |
| fcm | push (ios, android, web) | see Push providers | NORQ_FCM_* |
| apns | push (ios) | see Push providers | NORQ_APNS_* |
| expo | push (ios, android) | see Push providers | NORQ_EXPO_* |
| webpush | push (web) | see Push providers | NORQ_WEBPUSH_* |
Full Resend example:
providers:
resend:
config:
api_key: ${RESEND_API_KEY}
from: norq@alerts.example.com
from_name: "Acme" # optional — renders as "Acme <norq@alerts.example.com>"
reply_to: support@example.com # optional — string or list
suprsend:
config:
workspace_key: ${SUPRSEND_WORKSPACE_KEY}
workspace_secret: ${SUPRSEND_WORKSPACE_SECRET}reply_to accepts either a single address or a list:
providers:
resend:
config:
reply_to:
- support@example.com
- billing@example.comPer-call recipient extras (CC / BCC) live on the --to JSON for the CLI and on the SDK Recipient shape; they aren’t a config concern. See the CLI reference and SDK pages for the exact shape.
SendGrid is configured with the same shape as Resend:
providers:
sendgrid:
config:
api_key: ${SENDGRID_API_KEY}
from: norq@alerts.example.com
from_name: "Acme"
reply_to:
- support@example.com
- billing@example.comIdempotency on SendGrid is best-effort. Resend honours the
Idempotency-Key:HTTP header for true dedup; SendGrid doesn’t. The SDK still supplies an idempotency key (auto-generated UUID v4 per send when you don’t pass one), but on the SendGrid path it lands incustom_args.idempotency_key— visible in the SendGrid Activity feed and webhook events but does not dedup retries. For applications that retry on transport errors and need exactly-once semantics, layer your own dedup at the application level.
Console provider
The console provider prints compiled payloads to stdout. Use it for local development and CI – no credentials needed:
providers:
console:
config: {}
routing:
email: console
sms: console
slack: consoleCustom providers
Define custom providers inline in norq.config.yaml. Custom providers support email, SMS, and WhatsApp only. Use Handlebars-style placeholders for config values ({{config.*}}), secrets ({{secrets.*}}), and message input ({{input.*}}).
Single-channel example (Mailgun):
providers:
mailgun:
channels: [email]
config:
domain: { required: true }
api_key: { required: true, secret: true }
auth:
type: basic
username: api
password: "{{secrets.api_key}}"
send:
method: POST
url: "https://api.mailgun.net/v3/{{config.domain}}/messages"
body:
from: "notifications@{{config.domain}}"
to: "{{input.recipient}}"
subject: "{{input.subject}}"
html: "{{input.html}}"
response:
success_status: [200]
message_id_path: "$.id"Multi-channel example (internal gateway):
providers:
internal:
channels: [email, sms]
config:
base_url: { required: true }
api_key: { required: true, secret: true }
auth:
type: bearer
token: "{{secrets.api_key}}"
send:
email:
method: POST
url: "{{config.base_url}}/v1/email"
body:
to: "{{input.recipient}}"
subject: "{{input.subject}}"
html: "{{input.html}}"
response:
success_status: [200]
message_id_path: "$.id"
sms:
method: POST
url: "{{config.base_url}}/v1/sms"
body:
to: "{{input.recipient}}"
text: "{{input.body}}"
response:
success_status: [200]
message_id_path: "$.id"Auth types: bearer (token), basic (username + password), api_key (in header or query param).
Provider naming
Every entry under providers: can carry an optional type: field that decouples the instance name from the provider implementation. When type is absent, the map key is used as the type (the existing behaviour). When type is present, the key becomes an arbitrary instance name and type names the implementation.
This allows multiple instances of the same provider — for example, two FCM service accounts routing to different app bundles:
providers:
fcm-android:
type: fcm
config:
service_account_json: ${FCM_ANDROID_SA}
fcm-ios:
type: fcm
config:
service_account_json: ${FCM_IOS_SA}Route each instance explicitly so Norq knows which one to use per channel:
routing:
push: fcm-android
push.ios: fcm-iosFor built-in types (resend, sendgrid, suprsend) without a type: field, the map key must match the type name — type: is only required when the key differs from the implementation.
Push providers
fcm — Firebase Cloud Messaging
providers:
fcm:
config:
projectId: my-firebase-project
serviceAccount: ./secrets/fcm-sa.json # file path, ${ENV_VAR}, or inline JSONEnvironment fallback: NORQ_FCM_PROJECT_ID, NORQ_FCM_SERVICE_ACCOUNT.
apns — Apple Push Notification service
providers:
apns:
config:
keyId: ABC1234567
teamId: DEF1234567
bundleId: com.example.app
keyPath: ./secrets/AuthKey_ABC1234567.p8 # OR keyPem: "${APNS_P8}"
production: false # default: sandboxEnvironment fallback: NORQ_APNS_KEY_ID, NORQ_APNS_TEAM_ID, NORQ_APNS_BUNDLE_ID, NORQ_APNS_KEY_PATH (or NORQ_APNS_KEY_PEM), NORQ_APNS_PRODUCTION.
webpush — VAPID Web Push
providers:
webpush:
config:
vapidPublicKey: BPxx...
vapidPrivateKey: "${VAPID_PRIVATE_KEY}"
subject: "mailto:ops@example.com"Environment fallback: NORQ_WEBPUSH_VAPID_PUBLIC_KEY, NORQ_WEBPUSH_VAPID_PRIVATE_KEY, NORQ_WEBPUSH_SUBJECT.
expo — Expo Push Service (React Native)
providers:
expo:
config:
accessToken: "${EXPO_ACCESS_TOKEN}" # optional; anonymous if omitted (rate-limited)Environment fallback: NORQ_EXPO_ACCESS_TOKEN.
Secret values
Any field marked as a secret accepts three forms:
| Form | Example | Notes |
|---|---|---|
| Env var ref | "${MY_SECRET}" |
Resolved from process env. Errors if unset. |
| File path | ./secrets/key.p8, /abs/path.json, ~/.norq/key.pem |
Read at config-load time. Recognized by .////~/ prefix or by extension .json/.p8/.pem/.key. Missing file is an error. |
| Inline | '{"type":"service_account",...}', "-----BEGIN PRIVATE KEY-----..." |
Recognized by leading { (JSON) or -----BEGIN (PEM). Otherwise treated as opaque (e.g. an Expo access token). |
This resolver applies to every secret field in the config — built-in push provider credentials (FCM service-account JSON, APNs .p8 keys, VAPID private keys), custom provider secret: true fields, and registry headers. Push provider errors that surface from credential issues (e.g. provider/fcm-legacy-server-key) often resolve by switching from a stale env var to one of the file-path or inline forms above.
Routing
The routing: section maps each channel to the provider name that delivers it. The value must match a key defined under providers:. Only channels with a routing entry are deliverable via norq send.
routing:
email: resend
sms: twilio
slack: consoleIf a channel has no routing entry, norq send skips it (even if the template exists). For custom providers, use the custom provider’s key name:
providers:
mailgun:
channels: [email]
# ...
routing:
email: mailgunStrict mode
Strict mode controls whether the compiler refuses to render templates that reference data paths missing from the schema, use unknown pipes, or pass wrong-typed pipe arguments. Inspired by Handlebars’ strict mode — it catches typos like {{user.nam}} and {{x | uppercse}} at send time instead of silently coercing them to empty strings.
strict: true # default — refuse to render on invalid references| Key | Default | Description |
|---|---|---|
strict |
true |
When true, runtime validation rejects templates that reference variables missing from data.schema.yaml, use pipe names not in the registry, or pass arguments of the wrong type to a pipe. CLI: override per-invocation with --strict / --no-strict. SDKs: override per call with `strict: true |
What strict mode catches:
- Missing data paths —
{{user.nam}}when only{{user.name}}is in the schema. - Unknown pipes —
{{ x | uppercse }}(typo foruppercase). - Wrong-type pipe args —
{{ x | truncate "abc" }}(truncate expects a number). - Same checks inside frontmatter —
subject:,preheader:.
Legitimate exceptions where strict still passes:
- Paths inside
:::if pathconsequents (implicitly guarded). - Loop bindings inside
:::each collection as itembodies. - Anything piped through
| default "fallback".
Set strict: false only when you need permissive mode (missing variables render as empty strings, unknown pipes no-op).
Brand
Visual identity (colors, typography, spacing, radii, named styles, fonts, voice) lives in a separate brand.yaml file at your project root, not in norq.config.yaml. Set the path in config if it isn’t ./brand.yaml:
notifications: ./notifications
brand: ./design/brand.yaml # optional; auto-discovers ./brand.yaml otherwise
email:
contentWidth: "600px" # email-only rendering knob (channel-specific)| Key | Description |
|---|---|
brand |
Path to brand.yaml. Defaults to ./brand.yaml next to this config. Supports relative paths, absolute paths, and $ENV_VAR expansion. |
email.contentWidth |
Max width of the email content area (e.g., "600px"). Lives here, not in brand, because it’s channel-specific layout config. |
The legacy theme: block (and the top-level fonts: array) was dropped in favour of brand.yaml. See the dedicated Brand page for the full reference – token sections, alias resolution, color pipes, named styles, dark mode, fonts, voice, and per-template brand: frontmatter overrides.
Code generation
Generate type-safe SDK bindings from your data.schema.yaml files. Configure targets in the config file:
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 dateCodegen reads all data.schema.yaml files and generates one file per notification with short leaf names. The output directory mirrors the notification structure.
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 WelcomeDataimport { WelcomeData } from "./generated/norq/transactional/welcome";For full codegen details, see the Code Generation page.
Registries
Configure named registries for norq add and norq registry commands. Each registry maps a namespace prefix to a URL pattern.
registries:
"@acme": "https://registry.acme.com/r/{name}.json"The {name} placeholder is replaced with the item name at fetch time. norq add @acme/welcome fetches https://registry.acme.com/r/welcome.json.
URL string form
The simplest form is a namespace-to-URL mapping:
registries:
"@acme": "https://registry.acme.com/r/{name}.json"
"@oss": "https://templates.example.org/r/{name}.json"Full object form
For private registries that require authentication, use the object form with url and headers:
registries:
"@private":
url: "https://internal.company.com/r/{name}.json"
headers:
Authorization: "Bearer ${REGISTRY_TOKEN}"Headers support ${ENV_VAR} expansion, same as provider config. The environment variable must be set at runtime or norq doctor will report an error.
Default registry
When no namespace is specified (norq add welcome), the default public registry at norq.sh/r/ is used. Named registries override the default only for their namespace prefix.
Full example
# norq.config.yaml
notifications: ./notifications
brand: ./brand.yaml
email:
contentWidth: "600px"
strict: true
providers:
resend:
config:
api_key: ${RESEND_API_KEY}
twilio:
config:
account_sid: ${TWILIO_ACCOUNT_SID}
api_key_sid: ${TWILIO_API_KEY_SID}
api_key_secret: ${TWILIO_API_KEY_SECRET}
from: "+1234567890"
console:
config: {}
routing:
email: resend
sms: twilio
slack: console
push: fcm # channel-wide default for push
push.ios: apns # per-platform override (iOS tokens use APNs direct)
push.android: fcm
push.web: webpush
codegen:
- lang: typescript
out: ./src/generated/norq
registries:
"@acme": "https://registry.acme.com/r/{name}.json"Visual identity (colors, fonts, dark mode, named styles, defaults) lives in brand.yaml – see the Brand page for that schema. The legacy theme: block and top-level fonts: array were removed; both now live inside brand.yaml.
See Data Contracts for data.schema.yaml and data.samples.yaml format.