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
- Missed call workflows — automated SMS after caller hangs up
- Call routing strategies
- Business hours logic — constrain callback times
- SignalWire call flow builder — visual queue-back 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