Norq

Ruby SDK

The norq gem provides a client for sending and linting notification templates from Ruby applications.

Install

Coming soon. The norq gem is not yet published to RubyGems. See norq.sh for updates.

The SDK runs entirely in-process — no norq binary or daemon is invoked at runtime.

Quick start

require "norq"
 
norq = Norq::Client.new(project_dir: "./my-project")
 
result = norq.send("transactional/welcome",
  to: { email: "alice@example.com" },
  data: { user: { first_name: "Alice" } },
)
 
result.results.each do |r|
  puts "#{r.channel}: #{r.success ? 'OK' : r.error}"
end

Constructor

norq = Norq::Client.new(
  project_dir: "./my-project",
  strict: true,
  recipient_resolver: my_resolver,
)
Option Type Description
project_dir String Path to the norq project directory. Required — templates are read from this directory and compiled in-process.
strict Boolean Reject sends that reference missing data or unknown pipes. Default true.
recipient_resolver #resolve Resolves user ID strings to Recipient objects

API

send(notification, to:, data: nil, sample: nil, channels: nil, dry_run: false, strict: nil, idempotency_key: nil)

Compile and deliver a notification.

result = norq.send("transactional/welcome",
  to: { email: "user@example.com",
        cc: ["audit@example.com"] },              # email-channel CC/BCC arrays
  data: { user: { first_name: "Gaurav" } },
  channels: ["email"],
  idempotency_key: "order-shipped-ORD-123",       # optional — see below
  dry_run: false)
 
result.results.each { |r| puts "#{r.channel}: #{r.success}" }
result.skipped.each { |s| puts "Skipped #{s.channel}: #{s.reason}" }

idempotency_key: is forwarded to providers that honour it (Resend, SuprSend) as the Idempotency-Key: header. When omitted, the SDK generates a UUID v4 per send and applies it to every channel; passing a stable user-derived key (order-shipped-ORD-123) extends dedup across retries and processes.

to: accepts an email-channel sub-shape with cc / bcc arrays — providers that support them (Resend) include them in the request body; others ignore.

attachments: accepts an array of hashes: {filename:, content: (binary String → SDK base64-encodes; ASCII String → passed through), content_type: (optional, inferred from extension), content_id:, disposition:}. Use File.binread("path") to feed bytes; the SDK detects the encoding and base64-encodes binary strings transparently. Inline images use disposition: "inline" + content_id: "logo" and are referenced from the HTML as <img src="cid:logo">. Provider caps: Resend 40 MB total, SendGrid 30 MB. See the Attachments reference for the full whitelist.

batch(notification, channel:)

Create a batch for sending a single channel template to many recipients. The template is prepared once, then each add call renders with per-recipient data. Requests are buffered and auto-flushed at the provider’s max batch size.

batch = norq.batch("transactional/welcome", channel: "email")
users.each do |user|
  batch.add(to: user.email, data: { user: { first_name: user.name } })
end
results = batch.flush

lint(notification = nil)

results = norq.lint              # all
results = norq.lint("transactional/welcome")   # one
 
results.each do |result|
  result.diagnostics.each do |d|
    puts "#{d.severity}: #{d.message} [#{d.rule}]"
  end
end

RecipientResolver

To send by user ID instead of explicit recipient details, configure a recipient_resolver:

resolver = ->(user_id) {
  user = db.get_user(user_id)
  { email: user.email, phone: user.phone }
}
norq = Norq::Client.new(project_dir: "./my-project", recipient_resolver: resolver)
 
norq.send("transactional/welcome", to: "user-123", data: { user: { first_name: "Alice" } })

Or as a class:

class MyResolver
  def initialize(db)
    @db = db
  end
 
  def resolve(user_id)
    user = @db.find_user(user_id)
    Norq::Recipient.new(email: user.email, phone: user.phone)
  end
end
 
norq = Norq::Client.new(project_dir: "./my-project", recipient_resolver: MyResolver.new(db))

Error handling

begin
  norq.send("transactional/welcome", to: "user-123", data: { ... })
rescue Norq::NorqError => e
  puts "Norq error: #{e.message}"
end

Sending push notifications

Push delivery is per-token. A recipient’s push.tokens array contains every device the user has registered, possibly across ios, android, and web. The SDK fans the send out one HTTP call per token and emits one entry in result.results per token.

Token shape

{
  token: "...",                       # FCM/APNs/Expo token, or Web Push endpoint URL
  platform: "ios" | "android" | "web", # required — Norq routes per platform
  provider: "apns",                   # optional — overrides routing.push / routing.push.<platform>
  keys: { p256dh: "...", auth: "..." }, # required iff platform == "web"
  environment: "sandbox" | "production", # APNs only; overrides provider production flag
}

Mixed-platform recipient

result = norq.send("transactional/order-shipped",
  to: {
    push: {
      tokens: [
        { token: "fcm-android-token", platform: "android" },
        { token: "apns-ios-token", platform: "ios", environment: "production" },
        {
          token: "https://updates.push.services.mozilla.com/wpush/v2/...",
          platform: "web",
          keys: { p256dh: "BNc...", auth: "tBHI..." },
        },
        { token: "ExponentPushToken[xxx]", platform: "ios", provider: "expo" },
      ]
    }
  },
  data: { user: { first_name: "Gaurav" }, order: { id: "ORD-123" } },
  channels: ["push"])

Per-token result fields

Each push result carries two extra fields:

Field Meaning
token_index Index into the original recipient[:push][:tokens] array.
token_status Normalised cross-provider status: "invalid" (FCM UNREGISTERED, APNs BadDeviceToken, Web Push 404/410) or "transient" (5xx, rate limits). nil on success.

Reaction guide

token_status What it means What to do
"invalid" Token is permanently dead. Drop from DB; don’t retry.
"transient" Provider had a temporary failure. Retry with backoff; token still good.
nil on success Delivery accepted. No action.
result.results.each do |r|
  next unless r.channel == "push"
  token = recipient[:push][:tokens][r.token_index]
  case r.token_status
  when "invalid"
    db.delete_push_token(user_id, token[:token])
  when "transient"
    retry_queue.enqueue(user_id, token, retry_at: Time.now + 60)
  end
end

See Push provider errors for the full set of per-token failure modes.

Web Push runtime caveat (webpush gem)

Web Push delivery in the Ruby SDK depends on the upstream webpush gem (currently ~> 1.1). On Ruby 4.x + OpenSSL 3.x the webpush gem has two known incompatibilities:

  1. It mutates OpenSSL::PKey::EC instances after construction (e.g. key.private_key=) — forbidden in OpenSSL 3 (pkeys are immutable on OpenSSL 3.0).
  2. It calls HKDF#read, which was renamed to HKDF#next_bytes in newer hkdf releases.

These are upstream gem bugs, not Norq bugs. The Norq Ruby SDK code is correct; the webpush gem itself fails to load encrypted requests under modern Ruby.

Workarounds:

  • Ruby 3.2 / 3.3 with OpenSSL 1.1 or 3.0 — the gem works fine; no action needed.
  • Ruby 4.x + OpenSSL 3.6+ — pin webpush to a fork that supports modern OpenSSL, or use a different push provider for web (FCM Web SDK works through fcm).

Expo, FCM, and APNs paths are unaffected — they go through Norq::Auth::Bearer / Norq::Auth::OAuth2GoogleSA / Norq::Auth::AppleJwt (with apnotic for HTTP/2), none of which touch the webpush gem.

Codegen

norq codegen --lang ruby --out lib/generated/norq_types.rb

Generates typed Ruby classes for each notification’s data shape.