Quiet hours
Hold email, SMS, and webhooks until a recipient's quiet hours end, in their own time zone, while the inbox updates right away.
Setting Quiet Hours
Quiet hours belong to the recipient, not the notification. Set them with the recipient’s time zone:
await notify.upsertRecipient({
id: "user_123",
email: "jane@example.com",
quietHours: { start: "22:00", end: "08:00", timezone: "America/Toronto" },
})| Field | Format |
|---|---|
start | 24-hour "HH:MM", the start of the quiet window |
end | 24-hour "HH:MM", when it ends. It can be earlier than start to cross midnight |
timezone | An IANA name such as "Europe/Berlin". Defaults to UTC |
An invalid time or time zone makes upsertRecipient() throw INVALID_QUIET_HOURS. Pass quietHours: null to clear them; leaving the field out keeps what is stored. A window whose start equals its end is never active. Daylight saving changes are handled: 08:00 means 08:00 on the recipient’s clock.
What Happens
When a send arrives inside the recipient’s window:
- The inbox item is written now. It does not buzz anyone; the recipient sees it whenever they next look.
- Email, SMS, and webhook are held, but only the ones the recipient’s preferences allow.
send()lists them inresult.deferredChannels, and each gets a skipped delivery record with the reasonquiet_hours_deferred. - A scheduled send is stored for the moment the window ends, and a timer in the process fires it then.
When it fires, preferences are resolved again, so a channel the recipient turned off overnight stays off. The held channels are then delivered as usual, with retries, under the same notification record. Nothing is dropped: quiet hours delay messages, never discard them.
Restarts and Flushing
Scheduled sends are stored through your database adapter, but their timers live in the process. After a restart or deploy, call recoverScheduledSends() to deliver every row that is already due; future rows are left for later. It is safe to call on boot and on an interval:
await notify.recoverScheduledSends()
setInterval(() => notify.recoverScheduledSends(), 60_000)| Call | Delivers |
|---|---|
recoverScheduledSends() | Rows whose time has passed. The one to use in production |
flushScheduledSends({ force: false }) | The same |
flushScheduledSends() | Every row now, ignoring quiet hours. For tests and admin tools |
Each row is claimed before delivery and only removed once delivery succeeds; a failure puts it back. Two workers flushing at once will not deliver the same row twice.
With Other Features
| Feature | Interaction |
|---|---|
| Preferences | Resolved before quiet hours, and again when the held send fires |
| Rate limits | Checked first; a held send still counts toward the limit |
| Digests | A digest that closes inside quiet hours is held, then delivered once |
required | Required notifications are held too; quiet hours are not a preference |
| Fallbacks | A held channel is not a failure, so it does not trigger a fallback |
Letting Users Choose
Quiet hours are set on the server with upsertRecipient(); the handler has no route for them. Add a small settings form that posts to your own endpoint, and use the browser’s Intl.DateTimeFormat().resolvedOptions().timeZone as the default time zone.
Debugging
explain()returnsquietHours: { active, resumesAt }, and each held channel’s outcome isdelayed. See Explain and dry run.- The timeline has a
quiet_hours.deferredevent per held channel. - “The email arrived hours late” usually means it was held until the window ended, as designed. “It never arrived” after a deploy usually means nothing called
recoverScheduledSends().