End-to-End Integration: Inbound
Handle calls your customers make to you. Unlike outbound, an inbound call isn't started through the REST API — it begins the moment someone dials your worker's number. Your webhook endpoint does all the live work: recognizing the caller, serving tools, and logging results.
The scenario: your receptionist worker Snorre answers calls on the company number. When a call comes in, you identify the caller by phone number, inject their history as context, let Snorre check the calendar and book appointments through your tools, and sync everything to your CRM when the call ends.
Covering outbound instead? See End-to-End Integration: Outbound. The webhook handler is largely shared between the two — the difference is who initiates the call and what you do in call.worker_request.
Anatomy of one inbound call
No REST request from you — the timeline starts with the customer dialing. Blue dots are sync events (Threll waits for your response); green dots are async notifications.
Customer dials your worker's inbound numberThe call enters Threll via your telephony integration. Nothing required from you yet.
call.worker_requestsyncFired on inbound call entry, before the worker picks up. customer.number is the caller's number — your moment to recognize them and return configuration overrides.
call.status_updateasyncLifecycle changes stream in as the worker answers — direction on the call is inbound.
call.transcriptasyncTranscript turns arrive as they're committed, for both speakers.
call.tool_callsyncSnorre invokes your external tools — check_availability, then book_appointment. The caller waits on each response.
call.endedasyncThe final event. Now fetch the results over REST.
…/phone-calls/{id} · …/transcript · …/audio/urlYou pull the analysis, transcript, and recording, and log the interaction in your CRM.
Step 1 — Set up
- Configure a worker with an
inboundPhoneNumberthrough your telephony integration. - Register a webhook subscription and store its
whsec_…signing secret. A worker-scoped subscription is a good fit here — sync events route to it in preference to the account-wide one. - Register the tools the worker may call mid-conversation (e.g.
check_availability,book_appointment) withprovider="external".
Step 2 — Identify the caller
For inbound calls, call.worker_request is where the integration earns its keep. The payload gives you the caller's number — look them up and return configuration overrides so the worker answers with full context:
case 'call.worker_request': {
// data.direction === 'inbound' — customer.number is the caller ID
const caller = await crm.findByPhone(data.customer.number);
if (caller) {
return res.json({
context: [
`Caller is ${caller.firstName} ${caller.lastName} (${caller.tier} customer).`,
`Open items: ${caller.openTickets.join(', ') || 'none'}.`,
`Last appointment: ${caller.lastAppointment ?? 'never'}.`,
].join('\n'),
});
}
// Unknown number — let the worker handle it as a new caller
return res.json({
context: 'Caller not found in CRM. Treat as a new lead and collect their name.',
});
}if event["type"] == "call.worker_request":
data = event["data"]
# data["direction"] == "inbound" — customer.number is the caller ID
caller = crm.find_by_phone(data["customer"]["number"])
if caller:
context = (
f"Caller is {caller.first_name} {caller.last_name} "
f"({caller.tier} customer).\n"
f"Open items: {', '.join(caller.open_tickets) or 'none'}.\n"
f"Last appointment: {caller.last_appointment or 'never'}."
)
else:
context = (
"Caller not found in CRM. Treat as a new lead "
"and collect their name."
)
return jsonify({"context": context})The caller is on the line while this request is in flight — keep your CRM lookup fast, and respond with an empty object {} rather than stalling if you have nothing to add.
Step 3 — Serve mid-call tools
Booking is a two-tool dance: the worker first checks availability, offers the caller a slot, then books it. Each invocation arrives as a call.tool_call and pauses the conversation until you respond:
case 'call.tool_call': {
const { name, arguments: args, toolCallId } = data;
log.info('tool call', toolCallId, name, args);
switch (name) {
case 'check_availability': {
const slots = await calendar.freeSlots(args.date);
return res.json({ result: { slots } });
}
case 'book_appointment': {
const booking = await calendar.book({
slot: args.slot,
reason: args.reason,
});
return res.json({
result: { confirmed: true, reference: booking.reference },
});
}
default:
return res.json({ result: { error: `unknown tool ${name}` } });
}
}if event["type"] == "call.tool_call":
data = event["data"]
name, args = data["name"], data["arguments"]
if name == "check_availability":
slots = calendar.free_slots(args["date"])
return jsonify({"result": {"slots": slots}})
if name == "book_appointment":
booking = calendar.book(
slot=args["slot"],
reason=args.get("reason"),
)
return jsonify({
"result": {"confirmed": True, "reference": booking.reference}
})
return jsonify({"result": {"error": f"unknown tool {name}"}})Step 4 — Finalize when the call ends
Identical to the outbound flow: when call.ended arrives, wait for postProcessingStatus to reach completed, then fetch the call, transcript, and audio URL and write them to your CRM. The outbound guide's finalizeCall works unchanged.
Two inbound-specific touches worth adding:
- Create the missing contact. If the caller wasn't in your CRM at
call.worker_requesttime, create a lead now — the completed call'scustomer,dimensions, and transcript usually contain the name and details Snorre collected. - Route the follow-up.
nextActiontells you what the call concluded (e.g.book_meeting,escalate_to_human) — wire it to your ticketing or task system so missed intents don't die in a transcript.
Inbound vs. outbound at a glance
| Aspect | Outbound | Inbound |
|---|---|---|
| Call starts | You: POST …/phone-calls | Customer dials the worker's number |
call.worker_request | Refresh context for a call you planned | Identify an unexpected caller by customer.number |
| Customer record | You supply it in the create request | Often unknown — look up by phone, create after the call |
direction field | outbound | inbound |
After call.ended | Same: poll post-processing, fetch transcript + audio, sync to CRM | |