Skip to main content

Best-time-to-dial

A dialer list uploaded with no timing signal dials top-to-bottom: every contact gets next_attempt_at stamped to the moment of upload, and both claim paths — an agent polling for the next call, or the pacing scheduler in progressive / predictive / agentless mode — pick contacts in next_attempt_at order. Two contacts at opposite ends of the day answer at very different rates, and a flat import order treats them identically. Best-time-to-dial makes the first attempt per-contact. Each row lands in the queue stamped with the window the engagement model predicts that contact is most likely to answer, and the list’s natural claim order — always earliest scheduled first — re-ranks itself accordingly. Messaging campaigns already do this for sends (smart send); this guide is the dialer equivalent.

1. The concept — ranked scheduling vs import order

Without the opt-in, a 5,000-contact upload produces 5,000 rows all scheduled for now. The dialer works them in whatever order the import produced them — the first row of the CSV gets the first call at 09:02, even if that contact historically answers at 18:00. With the opt-in, each contact’s first next_attempt_at points at their predicted best answer window. A contact who reliably picks up on weekday evenings is scheduled for this evening; one with no history at all gets a timezone-aware local-morning slot. Same list, same claim paths — the queue order just now reflects when people answer, not when the CSV was sorted. Two things do not change:
  • Only the first attempt is re-stamped. Retries after a no-answer still follow your campaign’s max_attempts_per_contact and retry pacing — best-time-to-dial shapes the entry point, not the retry ladder.
  • Compliance gates are untouched. Quiet-hours resolution still runs on every dial at dispatch time, so a predicted window can never push a call outside the permitted calling window. Consent, do-not-call, and the abandon ceiling gate exactly as before — see Launch an outbound dialer campaign.

2. Opt in — set the campaign metadata flag

The toggle is per campaign, stored in campaign metadata — no schema migration, no separate endpoint. Set it at campaign creation (or with a PATCH before you upload the list):
The flag must be the boolean true. A string "true" or any other truthy value is ignored — the campaign behaves as if it never opted in. Contacts uploaded before you set the flag keep their original immediate schedule; the re-stamping applies to lists uploaded while the flag is on.

3. What the engine does with the flag

When a list upload lands on a campaign with metadata.best_time_to_dial: true, the enrollment path scores the batch against the engagement model before inserting the rows:
  1. Each contact is looked up by their contact id when the upload binds one, falling back to their E.164 number.
  2. The engagement engine returns a predicted answer window for the voice channel per contact. A contact with no voice history falls through to their cross-channel engagement pattern; a contact with no engagement history anywhere gets the local-morning fallback in their resolved timezone.
  3. Each row’s next_attempt_at is written with that predicted window instead of the upload timestamp.
From then on nothing new runs. The preview-mode GET /api/v1/dialer/next-call claim and the pacing scheduler both already claim the earliest-scheduled eligible contact — they inherited the re-ranked order for free. Before each batch the pacer works the same queue it always has; the queue just no longer reads in import order. The whole step is fail-open. If the engagement scoring is unavailable — a profiler outage, a brand-new tenant with no engagement data — the upload completes anyway and every contact enrolls immediately, exactly as an unsigned list would. The upload is never blocked by the optimization.

4. What to watch in the dashboard

The ranking shows up where pacing surfaces queue order:
  • Campaign stats (GET /api/v1/dialer/campaigns/{id}/stats, or the campaign detail view in the console). Compare answer rate and calls-per-hour against a previous campaign over the same list source. The 30-minute abandonment rollup behaves exactly as before — the pacer’s throttle logic is unchanged.
  • Per-contact schedule. Open a contact in the list view: an enrolled contact shows a future next_attempt_at in their predicted window rather than the upload minute.
  • Lift validation. To prove the timing earns its keep, run two campaigns off the same list source — one with the flag, one without — and compare the answer rate per dialed contact. Treat the unflagged campaign as your control, the same discipline as a campaign holdout.

5. Try it — cURL and console

Bind contact_id on each row where you have one. The engagement lookup keys on the contact id first, so a row tied to a real contact record gets that contact’s actual engagement history; a phone-only row gets the fallback window. The campaign metadata toggle is also available when you create the campaign in the console — Outbound → Campaigns → New campaign, set the toggle, then upload the list on the campaign’s Lists tab.

6. Troubleshooting — the ranking collapsed back to import order

Symptom: every enrolled contact shows next_attempt_at equal to the upload minute, as if the flag were off.
  • The flag never landed. Read the campaign back (GET /api/v1/dialer/campaigns/{id}) and confirm metadata.best_time_to_dial is the boolean true — not the string "true", and not set after the upload you are inspecting. Only lists uploaded while the flag is on get re-stamped.
  • Engagement data is missing. For a new workspace, or a list of phone-only rows with no contact ids, there is no history to rank against. Every contact correctly resolves to the timezone-aware local-morning fallback — if contacts also lack a resolvable timezone, the fallback degenerates to immediate enrollment and the queue reads as import order. This is the fail-open design working as intended, not a stuck feature. Import the list with contact ids bound, or let a few campaigns accumulate engagement history first.
  • The scoring step failed open. A scoring outage during the upload leaves the whole batch immediate-enrolled. Re-uploading the list (or a fresh list on the same campaign) once engagement scoring is healthy re-applies the ranking to the new rows.

7. When best-time-to-dial is deliberately a no-op

Some traffic should not be re-timed, and isn’t:
  • Callbacks and scheduled re-dials. A callback_later disposition stamps callback_scheduled_at as the next attempt — the contact asked for a specific time, and that stamp wins over any predicted window. Regulated re-dial obligations on a contact behave the same: an explicit scheduled attempt always outranks the model.
  • Preview and manual dialing. In preview mode an agent claims and inspects one contact at a time, and a purely manual dial flow places calls by operator choice. Re-ranking the queue changes which contact surfaces first, but with a human picking, timing optimization has no throughput to win back — the flag is safe to leave on, just expect no measurable lift.
  • Retries. Only the first attempt carries a predicted window. Subsequent attempts follow the campaign’s retry pacing, so a contact you nearly reached at their best window does not get re-optimized into a different one.

See also