Scheduling
One-off, daily and weekly schedule rules — each in its own timezone, to all devices, a group or one subscriber, in any environment — with quiet hours, at-most-once dispatch and a run history.
Schedule rules send notifications for you on a cadence. One rule is:
- when — a one-off on a date, every day, or one day a week, at a time in the rule's own timezone;
- what — a fixed title + body, or copy the AI writes fresh at every send (AI-Written Notifications);
- who — all devices, a group (including the always-on Lapsed / Active / New) or one subscriber, in one environment.
Rules live in the dashboard's Push notifications → Scheduling tab (with an on/off switch, the next send, the last run, and quiet hours), the API below, and the MCP server. Every send goes through the ordinary send pipeline — one inbox entry per device, analytics included.
Cadence
frequency | Needs | Sends |
|---|---|---|
once | date ("YYYY-MM-DD") + timeOfDay | Once, on that date at that time. |
daily | timeOfDay | Every day at that time. |
weekly | dayOfWeek (0 = Sunday … 6 = Saturday) + timeOfDay | Every week on that day. |
- Each rule keeps its own wall clock.
timezoneis an IANA zone (America/New_York), DST included, andtimeOfDayis 24h"HH:MM". - A one-off must be at least 2 minutes away when it is created, moved or switched back on (
400 schedule_too_soon). Once it has gone out — or was missed — it is complete and can no longer be changed (409 rule_completed); create a new one. - No surprise send today. A daily or weekly rule created, resumed or rescheduled after today's time first sends at its next occurrence —
nextFireIsoshows exactly when. - The runner ticks every minute and dispatches an occurrence on the tick that reaches it, or within a 15-minute grace window if the server was briefly busy or restarting.
Audiences
audienceKind | With | Reaches |
|---|---|---|
all (default) | — | Every registered device in the rule's environment, with or without a subscriber id. |
group | audienceRef = the group key | Every device subscribed to a group — a manual group, or one of the always-on lapsed / active / new groups. |
sub | audienceRef = the subscriber id | Every device registered under that one subscriber id. |
skipIfActiveWithinDays(1–365) skips subscribers whose device was seen in the last N days — "they already opened the app".- An audience that resolves to nobody sends nothing and records an
emptyrun.
Quiet hours — hold, don't drop
Quiet hours are one window per project. While they are on, an occurrence whose time falls inside the window — read in that rule's timezone — is held and sent the moment the window ends. One-offs are held the same way; send-now pushes are never held.
{ "quietHoursEnabled": true, "quietHoursStart": "22:00", "quietHoursEnd": "07:00" }
The default window is 22:00–07:00, it may cross midnight, and start and end must differ. A rule's heldByQuietHours and effectiveFireIso say when a held next send will actually go out.
At most once
Every occurrence is sent at most one time, even across a restart: the runner claims the occurrence in the database before anything is sent (a second runner, or the same one after a restart, finds it claimed and skips it), and each send carries the idempotency key rule:<ruleId>:<occurrence>, so no device gets a second inbox entry for one occurrence. A run still sending 30 minutes later shows outcomeUnknown: true and is never sent again; a one-off whose whole grace window passed while the scheduler was not running is recorded missed. A blocked or failed send is not retried — the rule continues at its next occurrence.
Run statuses
GET …/schedules/:ruleId/runs returns a rule's 20 most recent runs, newest first.
status | Meaning |
|---|---|
sent | The push went out. audienceCount = devices targeted; title / body are the copy that was sent; sendId links it to the inbox and analytics. |
held | Quiet hours held it; a later run records the send. |
empty | Nobody to send to — nothing sent. |
blocked | A gate refused it: an expired trial, a group audience on the Premium plan, or a disabled environment. detail says which. |
error | The send failed — for example devices matched but no transport accepted the push (missing credentials). |
failed | An AI-written rule's copy could not be written — nothing was sent. |
missed | A one-off whose time passed while the scheduler was not running — nothing was sent. |
sending | Claimed and in flight. unknown: true when it never settled. |
API
These are account routes: a dashboard session or an MCP access token (Authorization: Bearer nnmcp_…) — an app token is refused with 403. Any member of the app's team may manage rules.
GET /api/apps/:appId/schedules — rules + quiet hours
GET /api/apps/:appId/schedules/upcoming — the next sends
POST /api/apps/:appId/schedules — create a rule
PATCH /api/apps/:appId/schedules/:ruleId — update / pause / resume
DELETE /api/apps/:appId/schedules/:ruleId — delete a rule
GET /api/apps/:appId/schedules/:ruleId/runs — run history
GET /api/apps/:appId/schedules/settings — quiet hours
PUT /api/apps/:appId/schedules/settings — set quiet hours
POST /api/apps/:appId/schedules/ai-sample — preview AI copy
Create a rule
A one-off to the church newsletter group, the Saturday before the bake sale:
{
"name": "Harvest bake sale",
"enabled": true,
"title": "Bake sale this Saturday",
"body": "Pies, bread and cookies in the church hall from 9:00. Everything goes to the food pantry.",
"frequency": "once",
"date": "2026-10-03",
"timeOfDay": "08:00",
"timezone": "America/New_York",
"audienceKind": "group",
"audienceRef": "church-newsletter",
"pushData": { "url": "/events/bake-sale" }
}
| Field | Required | Notes |
|---|---|---|
name | yes | Up to 120 characters. |
enabled | no | Default true — the rule is live unless you pass false. |
contentMode | no | "fixed" (default) or "ai" — see AI-Written Notifications. |
title, body | fixed copy | Up to 200 / 4,000 characters. |
appDescription, instructions, language, examples | AI copy | The brief — see AI-Written Notifications. |
frequency | yes | "once", "daily" or "weekly". |
date | once | "YYYY-MM-DD", in the rule's timezone. |
dayOfWeek | weekly | 0–6, 0 = Sunday. |
timeOfDay | yes | 24h "HH:MM". |
timezone | yes | IANA zone, up to 60 characters. |
audienceKind | no | "all" (default), "group" or "sub". |
audienceRef | group, sub | The group key or the subscriber id, up to 256 characters. |
environment | no | "production" (default), "staging" or "development". |
skipIfActiveWithinDays | no | 1–365 or null; skips subscribers seen in the last N days. |
pushData | no | A JSON object (or JSON string) delivered with every send, up to 16 KiB. |
bigPictureURL | no | An absolute https:// image url, up to 2,000 characters. |
The response is the saved rule — with its id, cadence, nextFireIso, heldByQuietHours, effectiveFireIso, lastRun and, for AI rules, its ai brief — plus a timing hint comparing the next send with the app's best send times.
PATCH …/schedules/:ruleId takes only what changes; every other field keeps its stored value, { "enabled": false } pauses a rule, and null clears pushData, bigPictureURL, skipIfActiveWithinDays or instructions. DELETE removes the rule and its run history — what it already sent stays in inboxes and analytics.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | bad_schedule | A rule field is missing or invalid — the message names it. |
400 | schedule_too_soon | A one-off less than 2 minutes away. |
400 | invalid_environment, environment_disabled | Not one of the three environments, or switched off for this app. |
400 | bad_app_id, bad_rule_id, bad_settings | A malformed id, or invalid quiet hours. |
404 | app_not_found, rule_not_found | The app is not one this account can access, or the rule is not on it. |
409 | rule_completed | The one-off already went out (or was missed). |
An app token instead of an account credential answers 403 with a plain-text message.
Every route is also an MCP tool — list_schedule_rules, create_schedule_rule, update_schedule_rule, delete_schedule_rule, set_quiet_hours, generate_ai_schedule_sample and friends. The tools take snake_case arguments and accept times like "9:00 AM"; create_schedule_rule requires enabled explicitly, because an enabled rule sends real pushes.