Providers
Norq compiles a template into a channel payload, then a provider
turns that payload into an HTTP request that the host (CLI or SDK)
executes. Ten providers ship in the box — covering email, SMS, push,
and the SuprSend hub — and any other delivery service plugs in as a
custom HTTP-adapter provider defined in norq.config.yaml.
How providers fit in
Every send() call follows the same three steps:
- WASM compile. The template is rendered against runtime data into
a channel-specific payload (
html,text,body,blocks, …). - Provider prepare. The provider’s
prepare_sendbuilds aProviderRequest— method, URL, headers, JSON body, and a typedAuthvalue. - Host execute. The CLI or SDK signs the auth, performs the HTTP
call, and reads the response per the request’s
response_hints(success status codes,message_idJSONPath, retryable status codes).
Providers never execute HTTP themselves. That split keeps secret-bearing crypto (FCM OAuth2 token exchange, APNs JWT signing, VAPID Web Push encryption) inside language-specific SDKs, while the request shape lives in WASM and is shared across CLI, Node, Python, Go, Java, and Ruby.
Routing
Map channels to providers in norq.config.yaml:
routing:
email: resend
sms: twilio
push: fcmPush additionally accepts per-platform keys, useful when different platforms route to different services:
routing:
push: fcm # fallback / single-key shorthand
push.ios: apns # iOS tokens go to APNs
push.android: fcm # Android tokens go to FCM
push.web: webpush # Web subscriptions go to VAPID Web PushResolution order
For each send Norq walks this priority list and picks the first match:
- Per-token override. A recipient push token may set
provider: <name>to bypass routing entirely (handy for multi-app workspaces with two FCM projects). routing.push.<platform>— platform-scoped entry, only consulted for push.routing.<channel>— channel-wide default.- Single-provider auto-fallback. If exactly one configured provider
supports the channel, Norq uses it. Good first-run UX — you don’t
have to write
routing:to send a single email. - Error.
provider/no-route-for-<channel>(orprovider/no-route-for-push.<platform>for push).
norq doctor validates routing at config-load time and prints a per-channel
table showing which provider was selected and how.
Built-in providers
| Provider | Channels | Auth | Source |
|---|---|---|---|
| Resend | API key (Bearer) | resend.com |
|
| SendGrid | API key (Bearer) | sendgrid.com |
|
| Twilio | sms | HTTP Basic | twilio.com |
| Bird | sms | AccessKey scheme | bird.com |
| FCM | push (ios, android, web) | Google service-account OAuth2 | Firebase |
| APNs | push (ios) | Apple .p8 ES256 JWT |
Apple |
| Expo | push (ios, android) | optional Bearer | Expo Push Service |
| Web Push | push (web) | VAPID (P-256 ECDSA + ECDH) | RFC 8030 / 8291 / 8292 |
| SuprSend | all + render | workspace key/secret (Bearer) | SuprSend hub |
console |
all | none — debug output to stdout | built-in |
norq provider list shows each as configured (config), configured (env),
credentials missing, or not configured so you can tell at a glance whether
a provider was loaded from norq.config.yaml, from NORQ_<NAME>_* env-var
fallbacks, or not loaded at all.
Credential resolution
Sensitive config values — API keys, FCM service-account JSON, APNs .p8
PEM, VAPID private keys, custom-provider secret: true fields — accept
three forms:
- Env-var reference.
${VAR_NAME}is replaced withprocess.env.VAR_NAMEat YAML load time. Unset variables resolve to the empty string and are treated as “not configured” (the provider returnsOk(None)instead of failing), so you can keep a config block in source and only populate it in environments that should send. - File path. Recognised by leading
./,../,/, or~/, or by a.json,.p8,.pem, or.keyextension. The file is read at load time and its contents become the config value.~/expands against$HOME. A missing file is a hard error — Norq never silently falls through to treating the path as a literal string. - Inline literal. Anything else is the value as-is — typical for
short tokens like Expo’s
accessToken.
The resolver lives in crates/core/src/provider/secret.rs and is shared
across every built-in and custom provider. See
Secret values for the canonical reference.
Idempotency
Every send() call carries an idempotency_key. When the caller does
not supply one, the SDK or CLI auto-generates a UUID v4 and applies the
same key to every channel in a multi-channel send. Provider behaviour
varies:
| Provider | Idempotency surface | Real dedup? |
|---|---|---|
| Resend | Idempotency-Key: header |
yes |
| SuprSend | Idempotency-Key: header |
yes |
| APNs | apns-id header (when key is a UUID) |
yes (within a short window) |
| SendGrid | custom_args.idempotency_key body field |
no — correlation only |
| Twilio | not surfaced | no |
| Bird | not surfaced | no |
| FCM | X-Norq-Idempotency-Key header |
no — observability only |
| Expo | not surfaced | no |
| Web Push | not surfaced | no |
| Console | printed envelope | no |
Supplying a stable user-derived key (order-shipped-ORD-123) extends real
dedup across retries and processes on the providers that support it; on the
others it stays useful as a log/webhook correlation token.
Batch send
batch() prepares a template once and renders it per recipient via WASM.
Most providers don’t expose a batch endpoint — the SDK iterates and calls
prepare_send per recipient. The exceptions:
| Provider | Native batch | Max items per request |
|---|---|---|
| Resend | POST /emails/batch |
100 |
| SuprSend | POST /v1/notification/trigger/bulk |
1000 |
| All others | — | — |
The provider’s max_batch_size() declares the limit; the SDK auto-flushes
at that boundary, so a 250-recipient batch() against Resend turns into
three HTTP calls (100 + 100 + 50) without you having to chunk manually.
Multi-channel send
Most providers handle exactly one channel. SuprSend is the exception —
its single /v1/notification/trigger endpoint accepts a channels array
covering email + SMS + push + Slack + WhatsApp + MS Teams in one request.
The SDK detects this via supports_multi_channel_send() and skips the
per-channel iteration loop.
Render operations
norq preview and norq screenshot build images of compiled email
output. These are render operations, not send operations, and require
a render-capable provider. Today only SuprSend declares
RenderCapability (Preview and Screenshot, email channel only).
Error codes
A short reference for the most common provider errors. The full catalogue with causes and fixes lives at Error reference.
| Code | Meaning |
|---|---|
provider/no-route-for-<channel> |
No provider configured for the channel — add routing.<channel>: <name>. |
provider/no-route-for-push.<platform> |
No push provider for this platform — add routing.push.<platform> or stamp the recipient token with provider:. |
provider/cli-http2-required |
CLI tried to deliver via APNs — APNs requires HTTP/2; use an SDK. |
provider/cli-oauth2-not-supported |
CLI tried to deliver via FCM — FCM requires the OAuth2 token exchange; use an SDK. |
provider/cli-vapid-not-supported |
CLI tried to deliver Web Push — VAPID encryption isn’t bundled in the CLI; use an SDK. |
provider/missing-vapid-keys |
A Web Push recipient token is missing keys.p256dh and keys.auth (RFC 8291). |
provider/fcm-legacy-server-key |
Configured FCM with the retired serverKey field (Google sunset 2024-06-20) — migrate to serviceAccount. |
Per-provider error codes (e.g. push/invalid-priority,
push/missing-platform) are documented on each provider’s page.
See also
- Custom providers — define HTTP-adapter providers in YAML for any service the built-ins don’t cover.
- Project configuration — the full
norq.config.yamlshape includingproviders:,routing:, and credential resolution. - Error reference — every coded error with cause and fix, including the runtime push and provider codes.
- CLI reference —
norq provider list,norq provider routing,norq doctor.