> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Best-time-to-dial: enroll each contact for the window they're most likely to answer

> Opt a dialer campaign into per-contact optimal timing at list upload — the dialer re-stamps each contact's first attempt into their predicted best answer window instead of dialing the list in import order.

# 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](/guides/campaign-end-to-end)); 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](/guides/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):

```json theme={null}
{
  "name": "Sales outbound — September",
  "mode": "predictive",
  "caller_id_e164": "+12025551234",
  "metadata": { "best_time_to_dial": true }
}
```

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

```bash theme={null}
export ORBIT_API_KEY="dv_live_sk_your_key_here"

# 1. Create the campaign with the opt-in flag
CAMPAIGN=$(curl -s -X POST "https://api.orbit.devotel.io/api/v1/dialer/campaigns" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Sales outbound — best-time test",
    "mode": "predictive",
    "caller_id_e164": "+12025551234",
    "metadata": { "best_time_to_dial": true }
  }')
CAMPAIGN_ID=$(echo "$CAMPAIGN" | jq -r '.data.id')

# 2. Upload the list — rows are stamped into each contact's predicted window
curl -s -X POST "https://api.orbit.devotel.io/api/v1/dialer/campaigns/$CAMPAIGN_ID/lists" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "september batch",
    "contacts": [
      {"phone_e164": "+14155550101", "contact_id": "ct_01H..."},
      {"phone_e164": "+14155550102"}
    ],
    "max_attempts_per_contact": 3
  }' | jq

# 3. Activate when ready
curl -s -X PATCH "https://api.orbit.devotel.io/api/v1/dialer/campaigns/$CAMPAIGN_ID" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}' | jq '.data.status'
```

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

* [Launch an outbound dialer campaign](/guides/outbound-dialer-campaign) — dial modes, pacing safety, dispositions, and the compliance gate stack
* [Dialer API](/api-reference/dialer) — request/response schemas for the campaign and list endpoints used above
* [Campaign end-to-end](/guides/campaign-end-to-end) — the messaging-side equivalent (smart send) and holdout-based lift measurement
