# 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 `timezone` at 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 |

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Weekly digest",
        "title": "Your week in review",
        "body": "Six new followers and two comments waiting.",
        "include_segments": ["seg_7b1c9e4a2f60"],
        "delayed_option": "last-active"
      }'
```

The fixed-hour form:

```json
{
  "title": "Good morning",
  "body": "Here's what changed overnight.",
  "delayed_option": "timezone",
  "delivery_time_of_day": "07:30"
}
```

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:

1. **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.
2. The release is set to the **next occurrence** of that hour, always within the following 24 hours.
3. 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.**
4. 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](sending-messages.md) — scheduling, quiet hours and frequency caps
- [Segments](segments.md) — choosing who is in the audience
- [Users and subscriptions](users-and-subscriptions.md) — where timezone and session data come from
- [Messages API reference](../api-handbook/02-messages.md)
