Best-hour delivery
Best-hour delivery holds a message back for each person until the hour of day they are most likely to be using your app. Instead of one send time for everyone, the audience is spread across the next 24 hours, one device at a time, in each device's own local hours.
There is a second, simpler per-user mode alongside it: send at the same local wall-clock time everywhere — 9 a.m. for the person in Lisbon and 9 a.m. for the person in Seoul.
Both are chosen with one field on the send.
When to use this page
Reach for best-hour delivery on content that is valuable but not time-critical: a weekly digest, a re-engagement nudge, a content drop, a feature announcement. Do not use it for anything where the moment matters — an OTP, a match starting, a delivery arriving, a price alert. Those should go out immediately.
Use the fixed-local-hour mode instead when the hour itself is the point: a "good morning" briefing, a lunchtime offer, a market-open summary.
Prerequisites
- Devices need to be reporting a
timezoneat registration. The SDKs do this for you. Anything absent or unrecognised falls back to UTC, deliberately, so delivery stays deterministic. - The app needs some activity history for the prediction to have anything to say. A brand-new app will mostly fall back to the app-wide pattern, or send immediately.
Choosing a mode
| Mode | Body fields | Behaviour |
|---|---|---|
| Best-hour delivery | "delayed_option": "last-active" | Each device gets its own predicted best local hour |
| Fixed local hour | "delayed_option": "timezone" plus "delivery_time_of_day": "09:00" | Every device gets the same local wall-clock time |
| Immediate | neither field | Sends now |
| Absolute schedule | schedule_at | Sends at one instant for everyone |
Code
The fixed-hour form:
Code
Accepted time formats are 21:45, 09:45:30 and 9:00AM; they are normalised to HH:MM. If you
set delayed_option: "timezone" with no time of day, 09:00 local is used. Supplying
delivery_time_of_day without delayed_option, or alongside "last-active", is a 400.
You can combine either mode with schedule_at. The window then opens at the scheduled instant and
each device is released at its own hour within the following 24 hours.
What trains the prediction
OpenPush keeps one small histogram per user: 24 buckets, one per local hour of day. Three things — and only three — add evidence to it:
| Signal | Weight |
|---|---|
| A subscription registration that counts as a new session | 1.0 |
| An app-foreground session ping | 1.0 |
| The first click on a notification | 3.0 |
A session only counts as new after 30 minutes have passed since the last one, so a user backgrounding and foregrounding the app repeatedly does not skew their own histogram. A replayed click receipt cannot inflate the counter either — only the first click on a given message counts.
Nothing else trains it. Notification opens, received and confirmed receipts, and custom
events all leave the model untouched. There is no CTR feedback loop.
The bucket is the hour in the user's local time, resolved at the moment of observation. That matters: storing UTC and converting later would be wrong off-season and wrong after someone relocates.
How old evidence fades
Evidence decays with a 30-day half-life by default. A click from a month ago counts half as much as one from today; from two months ago, a quarter.
Decay is folded into the stored counter on every write, so there is no nightly sweep job and no moment where the model is stale in bulk. Where two writes disagree about the clock — replicas drift — the later timestamp wins, so a slow replica cannot rewind someone's history.
The app-wide pattern
Alongside per-user histograms, OpenPush computes one app-wide shape: what the whole audience's day looks like, as a normalized 24-hour curve, plus the single busiest local hour.
Only evidence from the last 180 days feeds it — rows untouched for six months are excluded entirely — and it is decayed in aggregate the same way personal evidence is. It is computed once per message, not once per device, so it costs the same on a 1.5-million-device send as on a hundred-device one.
The app-wide pattern is what a brand-new user borrows until they have a history of their own.
How the two are blended
For each device, OpenPush scores all 24 local hours as this person's decayed evidence plus the app-wide pattern, weighted as though the app pattern were about eight observations of its own. The highest-scoring hour wins.
The practical consequence is a smooth handover with no threshold to fall off:
- A user with no history is scored entirely by the app-wide pattern, and lands on the app's busiest hour.
- A user with a couple of data points is still mostly app-shaped — their own evidence is roughly 20% of the answer.
- A user with forty data points is overwhelmingly their own — about 83%.
- After one half-life passes with no new activity, that same user drifts back toward the app pattern rather than being cut off.
There is no confidence threshold, no minimum sample size, and no cliff between "we know this person" and "we don't".
When there is nothing at all to go on — no personal evidence and no usable app pattern — the message is sent immediately rather than parked on an arbitrary hour. That is deliberate: the alternative would stack an entire cold audience onto midnight UTC.
The basis, and where you can see it
Every timed delivery records why its hour was chosen. Console surfaces render these as plain language, and the audience preview aggregates them:
| Basis | Meaning | Shown as |
|---|---|---|
| personal | Only the person's own evidence was available | "their own activity" |
| blended | A mix, with the personal share as a percentage | "83% their own activity" |
| app-default | No personal evidence; the app-wide pattern was used | "app-wide pattern" |
| no data | Nothing to go on — sent now | "no data" |
| timezone | The fixed-local-hour mode | "their local hour" |
POST /v1/apps/{app_id}/audience-preview reports this before you send, alongside the audience
size:
| Preview field | Meaning |
|---|---|
timed | How many devices will be parked for a later hour |
tz_known | How many devices have a timezone that actually resolves |
avg_pct | The average personal share across the audience — how "personal" this send will be overall |
no_data | How many devices have nothing to go on and will send immediately |
fallback_hour | The app-wide busiest hour those devices would otherwise use |
From predicted hour to actual release
Once an hour is chosen:
- A deterministic per-device jitter of up to one hour is added, derived from the subscription id. It is stable across retries and redrives — the same device always gets the same offset — and it exists so that ten thousand people whose best hour is 19:00 do not all arrive at 19:00:00.
- The release is set to the next occurrence of that hour, always within the following 24 hours.
- If quiet hours are enabled and the release lands outside the allowed window, it is pushed to the next opening. The predicted hour is composed with the window, never allowed to bypass it.
- If the resulting release is within five minutes of now, the message is simply sent now instead of being parked.
The fixed-local-hour mode follows the same last three steps, using the wall-clock time you gave instead of a prediction.
Timezone handling
Device timezones may be IANA names (Europe/Lisbon) or the raw offsets the SDKs sometimes report
(+05:30, -0800). Half-hour zones round to the nearest hour. Anything absent, malformed or
unknown falls back to UTC — a deterministic wrong answer rather than an unpredictable one.
tz_known on the audience preview is the honest count of how many devices have a timezone that
actually resolved.
Release cadence
Parked deliveries are released by the scheduler, which ticks every five seconds and releases up to 200 rows per tick — roughly 40 devices per second per server replica. On a large audience the spread you see is partly this cadence and partly the per-device jitter.
Quiet-hours holds and best-hour delivery holds share one release path and one queue, and go through the same worker as a first attempt. A device released at 08:00 therefore receives exactly the copy a device sent at 22:00 received. Devices that opted out while parked are marked so they stop matching, and never wake up.
How it interacts with everything else
| Interaction | Behaviour |
|---|---|
| Quiet hours | Composed, not bypassed. A predicted hour inside a muted window moves to the next opening |
| Frequency cap | Still wins. A capped device stays capped; timing does not buy it an exemption |
| Test devices | Bypass quiet hours and the cap as always |
| Segments | Unaffected. The audience is resolved first; timing is applied per device afterwards |
| A/B variants | Compatible. Arm assignment happens on the audience; each device is then timed individually |
schedule_at | Compatible. The 24-hour window starts at the scheduled instant |
| Message report | Audience counts everyone; parked devices show up as the send progresses, with delivering, spread_done, spread_total and spread_ends tracking the tail |
Limits
Be clear-eyed about what this model is and is not:
- The unit is a whole hour, plus up to an hour of deterministic jitter. There is no minute-level resolution.
- There is no day-of-week dimension. The histogram has 24 buckets, full stop — Sunday morning and Wednesday morning are the same bucket.
- The only engagement signals are the three listed above. No open rate, no dwell time, no per-message-type learning, no feedback from click-through into the predictor.
- No holdout, no cross-validation, no model versioning, no backtest. You cannot measure the lift of best-hour delivery against a control group from inside OpenPush.
- No per-message tuning. The blend weight, the decay half-life and the jitter are fixed platform-level constants set by the OpenPush team, not message body fields and not per-app settings.
- No send-rate throttle, drip or spread-over-N-hours control. The jitter and the release cadence are the only spreading mechanisms.
- Devices with no resolvable timezone are treated as UTC, which can concentrate them on an hour that means nothing locally.
FAQ
How long before a new app gets useful predictions? Immediately, in the sense that every device borrows the app-wide pattern from day one. Personal predictions become dominant for an individual after a few dozen sessions or clicks — around forty data points puts them at roughly 83% personal.
What happens to a user who moves countries? Their new sessions are bucketed in the new local time and the old ones decay away over a couple of months. There is no explicit relocation detection; the half-life does the work.
Does a best-hour delivery send finish in 24 hours?
The window is 24 hours, but the actual tail also depends on release cadence — about 40 devices per
second per replica. For very large audiences, budget accordingly and watch spread_ends on the
message report.
Can I see which hour a specific device was assigned? Not through a per-device API. The audience preview gives you the distribution before sending, and the message report gives you the aggregate progress.
Should I use last-active or timezone?
Use timezone when the hour is part of the message's meaning ("good morning"). Use last-active
when you just want the best chance of being seen. If your audience is concentrated in one or two
timezones, the two modes often produce similar results.
Why did some devices send immediately on a last-active message?
Either they had no evidence at all and no usable app pattern, or their computed release was already
within five minutes. Both are counted for you: no_data on the audience preview.
Related
- Sending messages — scheduling, quiet hours and frequency caps
- Segments — choosing who is in the audience
- Users and subscriptions — where timezone and session data come from
- Messages API reference