Python SDK
The norq Python package provides a client for sending and linting notification templates from Python applications.
Install
Coming soon. The
norqpackage is not yet published to PyPI. See norq.sh for updates.
The SDK runs entirely in-process — no norq binary or daemon is invoked at runtime.
Quick start
from norq import Norq
notif = Norq(project_dir="./my-project")
# Send
result = notif.send("transactional/order-shipped",
to={"email": "gaurav@example.com"},
data={"user": {"first_name": "Gaurav"}, "order": {"id": "ORD-123"}},
)Constructor
notif = Norq(
project_dir="./my-project", # project root (required)
strict=True, # runtime template validation (default: True)
logger=my_logger, # structured logger
recipient_resolver=my_resolver, # user ID -> Recipient
)| Option | Type | Description |
|---|---|---|
project_dir |
str |
Path to norq project root. Required — templates are read from this directory and compiled in-process. |
strict |
bool |
Reject sends that reference missing data or unknown pipes. Default True. |
logger |
Logger |
Structured logger for send/compile events |
recipient_resolver |
RecipientResolver |
Resolves user ID strings to Recipient objects |
API
send(notification, *, to, data=None, sample=None, channels=None, dry_run=False, strict=None, idempotency_key=None)
result = notif.send("transactional/welcome",
to={"email": "user@example.com",
"cc": ["audit@example.com"]}, # email-channel CC/BCC arrays
data={"user": {"first_name": "Gaurav"}},
channels=["email"],
dry_run=False,
idempotency_key="order-shipped-ORD-123", # optional — see below
)
for r in result.results:
print(f"{r.channel}: {'OK' if r.success else r.error}")
for s in result.skipped:
print(f"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 the same value to every channel. Pass a stable user-derived key (order-shipped-ORD-123) when you want dedup across processes too.
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 a list of dicts: {filename, content (bytes auto-base64'd; str passed through), content_type? (inferred from extension), content_id?, disposition? ("attachment" or "inline")}. 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 of inferred types.
batch(notification, *, channel)
Create a batch for sending a single channel template to many recipients. The template is compiled once, then rendered per-recipient. Requests auto-flush at the provider’s max batch size.
batch = notif.batch("transactional/welcome", channel="email")
# Add recipients (auto-flushes at provider's max batch size)
for user in users:
batch.add(to=user.email, data={"user": {"first_name": user.name}})
# Flush remaining and get all results
results = batch.flush()lint(notification=None)
results = notif.lint() # all
results = notif.lint("transactional/welcome") # one
for result in results:
for diag in result.diagnostics:
print(f"{diag.severity}: {diag.message} [{diag.rule}]")Recipient
from norq.types import Recipient
recipient = Recipient(
email="user@example.com",
phone="+1234567890",
)
# Or pass a dict:
result = notif.send("transactional/welcome", to={"email": "user@example.com"}, data={...})RecipientResolver
To send by user ID instead of passing a full Recipient object, implement a RecipientResolver:
class MyResolver:
def resolve(self, user_id: str) -> dict:
user = db.get_user(user_id)
return {
"email": user.email,
"phone": user.phone,
"slack": {"channel_id": user.slack_id} if user.slack_id else None,
}
notif = Norq(
project_dir="./my-project",
recipient_resolver=MyResolver(),
)
# Now send by user ID
notif.send("transactional/welcome", to="user-123", data={"user": {"first_name": "Alice"}})Sending push notifications
Push delivery is per-token. A recipient’s push.tokens list 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 returns 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 = notif.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"] list — match results back to the token. |
token_status |
Normalised cross-provider status: "invalid" (FCM UNREGISTERED, APNs BadDeviceToken, Web Push 404/410) or "transient" (5xx, rate limits). Absent 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 is still good. |
| (absent) on success | Delivery accepted. | No action. |
for r in result.results:
if r.channel != "push":
continue
token = recipient["push"]["tokens"][r.token_index]
if r.token_status == "invalid":
db.delete_push_token(user_id, token["token"])
elif r.token_status == "transient":
retry_queue.enqueue(user_id, token, retry_at=time.time() + 60)See Push provider errors for the full set of per-token failure modes.
Error handling
from norq import NorqError, NorqStrictError
try:
notif.send("transactional/welcome", to="user-123", data={...})
except NorqStrictError as e:
# Template references missing data or uses an unknown pipe.
for err in e.errors:
print(f"{err.kind}: {err.message} (line {err.line})")
except NorqError as e:
print(f"Norq error: {e}")Codegen
norq codegen --lang python --out src/generated/norq_types.pyGenerates typed dataclasses for each notification’s data shape.
Agent Toolkit
The Python SDK ships an Agent Toolkit module that exposes notifications as typed tools to LLM agents (OpenAI Chat Completions tool-calls, plus any framework that consumes JSON Schema). Locked recipient, allowlist scoping, deterministic idempotency keys, raw-HTTP-body redaction, and minimal-by-default result shape — see the Agent Toolkit page for the full API.