T
Telephony SOPKnowledge Base
Search
← All topics

Callback Scheduling — Queue-Back, Scheduled Callbacks, and Virtual Hold

runnable

Three callback patterns: queue-back (caller keeps place in queue without holding), scheduled callback (caller picks a specific time), and immediate callback (caller hangs up, agent dials when free). Reduces abandonment by 30-50% and lets queue time scale beyond what callers will tolerate on hold. Implemented in SWML via call termination + scheduled outbound dialing.

callbackvirtual-holdqueue-backschedulingcustomer-experience
Agent trigger phrases: callback scheduling · virtual hold · queue back · schedule callback voice · callback when available · save my place

Callback Scheduling

Hold time is the single largest source of call abandonment. After 90 seconds on hold, drop rates exceed 30%; after 5 minutes, 60%+. Callback scheduling lets callers hang up while preserving their queue position or scheduling a specific time — recovering most of those abandoned interactions.

Three callback patterns

1. Queue-back (virtual hold)

Caller is in a queue. System estimates wait time. Offers: "stay on hold, or hang up and we'll call you in about 12 minutes — you keep your place in line." Caller hangs up. When their turn comes, system dials them.

Best for: unpredictable queue depth, high-volume support, time-sensitive issues where the caller wants resolution today.

2. Scheduled callback

Caller picks a future time slot. System dials them at the scheduled time. No queue concept — purely calendar-driven.

Best for: sales callbacks, consultations, low-urgency issues, when current queue is empty but caller can't talk now.

3. Immediate callback (callback-when-free)

Caller submits a callback request. Next available agent dials them. No queue position, no specific time — just "when an agent is free."

Best for: quiet hours, after-hours signup, situations where the caller doesn't care about timing.

SWML — queue-back implementation

version: 1.0.0
sections:
  main:
    - answer: {}
    - play: say:All agents are busy. Estimated wait is 12 minutes.
    - prompt:
        play: say:Press 1 to hold, or 2 to keep your place in line and we'll call you back.
        max_digits: 1
    - cond:
        when: "${prompt_value} == '1'"
        then:
          - connect:
              to: queue:support
        when: "${prompt_value} == '2'"
        then:
          - request:
              url: https://your.api/queue-callback
              method: POST
              body:
                phone: "${call.from}"
                queue: support
                queued_at: "${call.created_at}"
                position: "${queue.position}"
          - play: say:Got it. We'll call you back in about 12 minutes. Goodbye.
          - hangup: {}

The queue-callback endpoint enqueues the callback into your backend:

@app.route("/queue-callback", methods=["POST"])
def queue_callback():
    db.execute("""
        INSERT INTO callback_queue (phone, queue, queued_at, position)
        VALUES ($1, $2, $3, $4)
    """, request.form["phone"], request.form["queue"], 
       request.form["queued_at"], request.form["position"])
    return "OK"

A worker dequeues callbacks in order, dials them, and connects to the next available agent.

SWML — scheduled callback

- prompt:
    play: say:When would you like us to call? Press 1 for in one hour, 2 for tomorrow morning, 3 for tomorrow afternoon.
    max_digits: 1
- request:
    url: https://your.api/schedule-callback
    method: POST
    body:
      phone: "${call.from}"
      slot: "${prompt_value}"
    save: schedule
- play: say:Confirmed. We'll call you at ${schedule.confirmed_time}. Have a great day.
- hangup: {}

The backend converts the slot to an absolute time:

@app.route("/schedule-callback", methods=["POST"])
def schedule_callback():
    slots = {
        "1": now + timedelta(hours=1),
        "2": next_business_day_at(9, 0),
        "3": next_business_day_at(14, 0),
    }
    when = slots[request.form["slot"]]
    schedule_callback_at(request.form["phone"], when)
    return jsonify(confirmed_time=when.strftime("%A at %-I:%M %p"))

AI receptionist + scheduled callback

For natural-language scheduling, use an AI agent:

- ai:
    prompt:
      text: |
        Help the caller schedule a callback. Ask for their preferred date and time.
        Today is ${now}. Use schedule_callback to confirm.
    SWAIG:
      functions:
        - function: schedule_callback
          parameters:
            type: object
            properties:
              phone: { type: string }
              when_iso: { type: string, description: "ISO 8601 datetime" }
              reason: { type: string }
          data_map:
            webhooks:
              - url: https://your.api/ai-schedule

The AI handles "next Tuesday at 3" and "tomorrow morning" parsing natively, much better than DTMF slots.

Outbound dialer — the callback executor

A worker reads the callback queue and dials at the right time:

import schedule
import time
from datetime import datetime, timezone

def callback_worker():
    while True:
        # Get callbacks due in the next 30 seconds
        due = db.fetch("""
            SELECT id, phone, queue, scheduled_for
            FROM callback_queue
            WHERE status = 'pending'
            AND scheduled_for <= now() + interval '30 seconds'
            ORDER BY scheduled_for
            LIMIT 5
        """)
        
        for cb in due:
            agent = find_available_agent(cb.queue)
            if not agent:
                continue  # try again next loop
            
            # Place outbound call to customer
            sw_call = client.calls.create(
                to=cb.phone,
                from_=cb.business_number,
                url=f"https://your.api/connect-callback?id={cb.id}&agent={agent.id}",
                machine_detection="DetectMessageEnd",
            )
            
            db.execute("UPDATE callback_queue SET status='dialing', call_sid=$1 WHERE id=$2",
                       sw_call.sid, cb.id)
        
        time.sleep(5)

When the customer answers, the SWML connects them to the agent:

@app.route("/connect-callback")
def connect_callback():
    cb_id = request.args["id"]
    agent_id = request.args["agent"]
    answered_by = request.form.get("AnsweredBy", "unknown")
    
    if answered_by in ("machine_start", "machine_end_beep", "machine_end_silence"):
        # Customer's voicemail — leave a message
        return Response("""
            <Response>
                <Say>We're returning your call but missed you. We'll try again, or call us back.</Say>
                <Hangup />
            </Response>
        """, mimetype="text/xml")
    
    # Customer answered live — connect to agent
    return Response(f"""
        <Response>
            <Say>Hi, this is the callback you requested. Connecting you now.</Say>
            <Dial>
                <Sip>{agent_id}@pbx.example.com</Sip>
            </Dial>
        </Response>
    """, mimetype="text/xml")

Estimating wait time

For queue-back to work, callers need an accurate wait estimate. Compute from queue depth + average call duration:

def estimate_wait(queue_name):
    in_queue = db.fetch_val("SELECT count(*) FROM callback_queue WHERE queue=$1 AND status='pending'", queue_name)
    avg_call_seconds = db.fetch_val("""
        SELECT avg(extract(epoch from end_time - start_time))
        FROM calls
        WHERE queue=$1 AND end_time IS NOT NULL
        AND end_time > now() - interval '1 hour'
    """, queue_name)
    available_agents = db.fetch_val("SELECT count(*) FROM agents WHERE queue=$1 AND status='available'", queue_name)
    
    if available_agents == 0:
        available_agents = 1  # assume one will free up soon
    
    seconds = (in_queue * avg_call_seconds) / available_agents
    return max(60, int(seconds))

Communicate the estimate in friendly units:

def humanize_wait(seconds):
    if seconds < 120:
        return "a minute or two"
    if seconds < 600:
        return f"about {seconds // 60} minutes"
    if seconds < 1800:
        return "10 to 30 minutes"
    return "about an hour"

Confirmation SMS

After a callback is scheduled, send an SMS confirming:

send_sms(
    to=cb.phone,
    from_=cb.business_number,
    body=f"Callback scheduled for {cb.scheduled_for.strftime('%A at %-I:%M %p')}. Reply CANCEL to cancel.",
)

Lets caller cancel without re-calling. Reply handler:

@app.route("/sms-inbound", methods=["POST"])
def sms_inbound():
    body = request.form["Body"].strip().upper()
    from_ = request.form["From"]
    
    if body in ("CANCEL", "STOP CALLBACK"):
        db.execute("UPDATE callback_queue SET status='cancelled' WHERE phone=$1 AND status='pending'", from_)
        send_sms(to=from_, body="Callback cancelled.")
    
    return Response("", mimetype="text/xml")

Reminders

For scheduled callbacks, send a reminder SMS 15 minutes before:

# Cron every minute
upcoming = db.fetch("""
    SELECT * FROM callback_queue
    WHERE status='pending'
    AND scheduled_for BETWEEN now() + interval '14 minutes' AND now() + interval '16 minutes'
    AND reminded = false
""")

for cb in upcoming:
    send_sms(to=cb.phone, body=f"Reminder: callback in 15 minutes at {cb.scheduled_for.strftime('%-I:%M %p')}")
    db.execute("UPDATE callback_queue SET reminded=true WHERE id=$1", cb.id)

Compliance — caller-initiated callbacks

A callback the caller specifically requested is not telemarketing. TCPA's robocall rules apply only to unsolicited calls. Caller-initiated callbacks are explicitly consented and exempt.

However:

  • Quiet hours still apply (8 AM - 9 PM caller's local time, federal default)
  • Opt-out must still be respected if the caller ever says STOP or hangs up multiple callbacks
  • Records of consent should be retained (the original IVR/AI interaction is the consent record)

Failure modes

| Failure | Cause | Recovery | |---|---|---| | Customer doesn't answer callback | Number went to voicemail | Leave VM, schedule retry in 2 hours, allow up to 3 retries | | Agent unavailable when customer answers | Agent went unavailable in the gap | Connect to next available agent or apologize and reschedule | | Customer rejects callback ("don't call me") | Caller changed mind | Mark as opt-out, suppress future callbacks | | Estimated wait too short, customer angry | Estimate algorithm bad | Tune average duration calculation, add buffer |

Common pitfalls

  • No callback worker monitoring — queue backs up silently when the worker dies. Alert on callback_queue.status='pending' AND scheduled_for < now() - interval '5 minutes'.
  • Dialing outside business hours — caller requested callback for "tomorrow morning" but you dial at 6 AM. Constrain to allowed hours.
  • No abandonment after failed callbacks — caller answered 3 times, declined each time. Stop dialing.
  • Caller ID mismatch — outbound caller ID different from the number they originally called. Confusing — they don't recognize it. Use the same number.
  • Long initial confirmation — caller is impatient. Keep it under 10 seconds.

Cost comparison — hold vs callback

Hold cost per call:

  • 15-minute hold = 15 minutes of inbound billing = ~$0.13 at $0.0085/min
  • Higher abandonment, lost revenue

Callback cost per call:

  • 30-second inbound + outbound 5-minute call = ~$0.08
  • Lower abandonment, retains the lead

Callback is cheaper and converts better. Almost always the right call.

Related patterns

References

  • TCPA 47 USC §227 — caller-initiated exemption
  • ContactBabel research on hold abandonment thresholds
  • ITU-T E.412 — Network management — Operational guidance for international intelligent network