Ruby SDK
The norq gem provides a client for sending and linting notification templates from Ruby applications.
Install
Coming soon. The
norqgem 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}"
endConstructor
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.flushlint(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
endRecipientResolver
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}"
endSending 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
endSee 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:
- It mutates
OpenSSL::PKey::ECinstances after construction (e.g.key.private_key=) — forbidden in OpenSSL 3 (pkeys are immutable on OpenSSL 3.0). - It calls
HKDF#read, which was renamed toHKDF#next_bytesin newerhkdfreleases.
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
webpushto a fork that supports modern OpenSSL, or use a different push provider for web (FCM Web SDK works throughfcm).
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.rbGenerates typed Ruby classes for each notification’s data shape.