Norq

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: console

Notifications path

The notifications key points to the root directory containing your notification folders:

notifications: ./notifications

All 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 email api_key, from, from_name (optional), reply_to (optional, scalar or list) NORQ_RESEND_API_KEY, NORQ_RESEND_FROM_ADDRESS
sendgrid email 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.com

Per-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.com

Idempotency 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 in custom_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: console

Custom 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-ios

For 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 JSON

Environment 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: sandbox

Environment 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: console

If 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: mailgun

Strict 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 for uppercase).
  • Wrong-type pipe args{{ x | truncate "abc" }} (truncate expects a number).
  • Same checks inside frontmattersubject:, preheader:.

Legitimate exceptions where strict still passes:

  • Paths inside :::if path consequents (implicitly guarded).
  • Loop bindings inside :::each collection as item bodies.
  • 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/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

Codegen 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 WelcomeData
import { 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.