BackCore concepts

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" },
})
FieldFormat
start24-hour "HH:MM", the start of the quiet window
end24-hour "HH:MM", when it ends. It can be earlier than start to cross midnight
timezoneAn 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 in result.deferredChannels, and each gets a skipped delivery record with the reason quiet_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)
CallDelivers
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

FeatureInteraction
PreferencesResolved before quiet hours, and again when the held send fires
Rate limitsChecked first; a held send still counts toward the limit
DigestsA digest that closes inside quiet hours is held, then delivered once
requiredRequired notifications are held too; quiet hours are not a preference
FallbacksA 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() returns quietHours: { active, resumesAt }, and each held channel’s outcome is delayed. See Explain and dry run.
  • The timeline has a quiet_hours.deferred event 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().