Push Channel
Norq compiles push templates to a structured JSON payload with title, body, image, and action URL. The payload works with FCM, APNs, and web push providers.
Pipeline: Markdown -> AST (parse tree) -> { title, body, image, action_url, platforms } JSON
Template files
push.md– Markdown modepush.json– Native JSON mode (for custom payload structures)
Frontmatter
---
title: "Order shipped!"
enabled: true
ios:
sound: "default"
badge: 1
android:
channel_id: "orders"
web:
icon: "https://cdn.example.com/icon.png"
---| Field | Required | Description |
|---|---|---|
title |
No | Notification title. Fallback: :::header content, then first heading. (lowest priority – see Title resolution priority) |
enabled |
No | true (default) or false. |
ios |
No | iOS overrides. Allowed keys: sound, badge, category, thread-id, mutable-content, content-available, interruption-level. Unknown keys fire push/unknown-ios-key; invalid interruption-level values fire push/invalid-interruption-level. |
android |
No | Android overrides. Allowed keys: channel/channel_id, priority (normal|high), color, icon, sound, tag, ttl. Unknown keys fire push/unknown-android-key; invalid priority values fire push/invalid-priority. |
web |
No | Web Push overrides. Allowed keys: icon, badge, image, requireInteraction, actions, tag, renotify. Unknown keys fire push/unknown-web-key. |
Directive compilation
| Directive | Push output |
|---|---|
:::header |
Title source (highest priority – see Title resolution priority) |
:::footer |
Ignored |
:::action |
action_url extracted from the first link |
:::callout |
Body text with warning symbol prefix |
:::hero |
image URL extracted for the push image field |
:::fields |
Body text lines (“Key: Value”) |
:::media |
Rendered as text (graceful degradation) |
:::columns |
Stacked as text |
:::list |
Text lines |
:::highlight |
Body text with star prefix |
:::centered |
Plain text (no alignment in push) |
:::raw |
Content extracted as text (graceful degradation) |
Example
---
title: "Order #{{order.id}} shipped"
---
::: hero

:::
Hey {{user.first_name}}, your order is on its way!
::: action
[Track Order]({{tracking_url}}){primary}
:::Compiled output
Norq compiles the above template into a structured push payload:
{
"title": "Order #ORD-123 shipped",
"body": "Hey Gaurav, your order is on its way!",
"image": "https://cdn.example.com/shipped-banner.png",
"action_url": "https://track.example.com/123",
"platforms": {
"ios": { "sound": "default", "badge": 1 },
"android": { "channel_id": "orders" },
"web": { "icon": "https://cdn.example.com/icon.png" }
}
}Title resolution priority
The push title is resolved in this order:
- Content of
:::headerdirective - First heading (
#,##, etc.) in the body titlefield in frontmatter- Empty string (linter warns)
Sending push notifications
Token shape
A recipient’s push.tokens is a list. Each entry must declare platform. Web Push tokens additionally require encryption keys.
{
"push": {
"tokens": [
{ "token": "abc123", "platform": "ios" },
{ "token": "def456", "platform": "android" },
{
"token": "https://updates.push.services.mozilla.com/wpush/v2/...",
"platform": "web",
"keys": { "p256dh": "...", "auth": "..." }
},
{ "token": "ExponentPushToken[xxx]", "platform": "ios", "provider": "expo" }
]
}
}| Field | Required | Notes |
|---|---|---|
token |
yes | Provider-specific token; for Web Push it is the subscription endpoint URL. |
platform |
yes | One of ios | android | web. Missing or invalid values fire push/missing-platform / push/invalid-platform. |
provider |
no | Overrides channel-level routing for this token. |
keys |
iff web |
{ p256dh, auth } from the browser’s PushSubscription. Missing keys fire push/missing-vapid-keys. |
environment |
no | APNs only. sandbox or production — overrides the provider-level production: flag for this token (useful for routing TestFlight builds at sandbox while the provider defaults to production). Invalid values fire push/invalid-environment. |
Routing precedence
For each token, the provider is resolved highest-specificity first:
token.provider— explicit per-token override.routing[push.<platform>]— e.g.push.ios: apns.routing[push]— channel-wide default.- Single-provider fallback — if exactly one push-capable provider is registered, use it automatically (good first-run UX for projects that only have FCM configured).
- otherwise: error
provider/no-route-for-push.<platform>.
Built-in providers
| Provider | Auth | Platforms | Notes |
|---|---|---|---|
fcm |
Google service account | ios, android, web (Chrome/Edge) | One endpoint, three platforms via apns/android/webpush overrides. |
apns |
Apple p8 JWT | ios | HTTP/2 only — not callable from CLI. |
expo |
Bearer (optional) | ios, android | Single endpoint; Expo proxies to FCM/APNs. |
webpush |
VAPID | web | All browsers (Firefox, Safari, Chrome, Edge). |
Testing
tests:
- name: "Push payload under 4KB"
channel: push
sample: "New user"
assert:
payload_bytes: { lte: 4096 }
- name: "Push has title"
channel: push
sample: "New user"
assert:
body: { not_contains: "" }Best practices
- Keep push body under 100 characters for good display on all platforms
- Use
:::herofor the notification image (extracted automatically) - The first
:::actionlink becomes the tap-through URL - Use platform overrides in frontmatter for platform-specific behavior (sounds, badges)
- Push content is plain text – bold/italic formatting is stripped