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

frequencyNeedsSends
oncedate ("YYYY-MM-DD") + timeOfDayOnce, on that date at that time.
dailytimeOfDayEvery day at that time.
weeklydayOfWeek (0 = Sunday … 6 = Saturday) + timeOfDayEvery week on that day.
  • Each rule keeps its own wall clock. timezone is an IANA zone (America/New_York), DST included, and timeOfDay is 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 — nextFireIso shows 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

audienceKindWithReaches
all (default)—Every registered device in the rule's environment, with or without a subscriber id.
groupaudienceRef = the group keyEvery device subscribed to a group — a manual group, or one of the always-on lapsed / active / new groups.
subaudienceRef = the subscriber idEvery 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 empty run.

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.

statusMeaning
sentThe push went out. audienceCount = devices targeted; title / body are the copy that was sent; sendId links it to the inbox and analytics.
heldQuiet hours held it; a later run records the send.
emptyNobody to send to — nothing sent.
blockedA gate refused it: an expired trial, a group audience on the Premium plan, or a disabled environment. detail says which.
errorThe send failed — for example devices matched but no transport accepted the push (missing credentials).
failedAn AI-written rule's copy could not be written — nothing was sent.
missedA one-off whose time passed while the scheduler was not running — nothing was sent.
sendingClaimed 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" }
}
FieldRequiredNotes
nameyesUp to 120 characters.
enablednoDefault true — the rule is live unless you pass false.
contentModeno"fixed" (default) or "ai" — see AI-Written Notifications.
title, bodyfixed copyUp to 200 / 4,000 characters.
appDescription, instructions, language, examplesAI copyThe brief — see AI-Written Notifications.
frequencyyes"once", "daily" or "weekly".
dateonce"YYYY-MM-DD", in the rule's timezone.
dayOfWeekweekly0–6, 0 = Sunday.
timeOfDayyes24h "HH:MM".
timezoneyesIANA zone, up to 60 characters.
audienceKindno"all" (default), "group" or "sub".
audienceRefgroup, subThe group key or the subscriber id, up to 256 characters.
environmentno"production" (default), "staging" or "development".
skipIfActiveWithinDaysno1–365 or null; skips subscribers seen in the last N days.
pushDatanoA JSON object (or JSON string) delivered with every send, up to 16 KiB.
bigPictureURLnoAn 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

StatusCodeMeaning
400bad_scheduleA rule field is missing or invalid — the message names it.
400schedule_too_soonA one-off less than 2 minutes away.
400invalid_environment, environment_disabledNot one of the three environments, or switched off for this app.
400bad_app_id, bad_rule_id, bad_settingsA malformed id, or invalid quiet hours.
404app_not_found, rule_not_foundThe app is not one this account can access, or the rule is not on it.
409rule_completedThe 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.