End-to-End Integration: Outbound
A complete workflow that combines the REST API and webhooks: start an outbound call, let the worker query your systems mid-conversation, stream the transcript live, and sync the results into your CRM when the call ends.
The scenario: your sales worker Harald calls a lead. During the call he looks up the lead's account in your CRM through an external tool. When the call completes, you fetch the analysis, transcript, and recording, and write everything back to the CRM. Receiving calls instead? See End-to-End Integration: Inbound.
Anatomy of one call
Here's every HTTP exchange for a single outbound call. Blue dots are sync events (Threll waits for your response); green dots are async notifications.
/v1/accounts/…/phone-callsYou start the call via the REST API. Response: the call in queued status.
call.worker_requestsyncRight before dialing, Threll asks your endpoint for worker configuration overrides — your chance to inject fresh context.
call.status_updateasyncLifecycle changes stream in as the call connects: ringing, then in progress.
call.transcriptasyncTranscript turns arrive as they're committed — one event per turn, for both speakers.
call.tool_callsyncHarald invokes your external lookup_account tool. The voice session pauses until you return the result.
call.endedasyncThe final event. No further events follow — now fetch the results over REST.
…/phone-calls/{id} · …/transcript · …/audio/urlYou pull the analysis, full transcript, and recording URL, and sync them to your CRM.
Step 1 — Set up
- Create an API key under Settings → Developer (see Getting Started).
- Register a webhook subscription pointing at
https://your-domain.com/webhookand store itswhsec_…signing secret. - Register the
lookup_accounttool on the worker withprovider="external", so tool calls are dispatched to your endpoint.
Step 2 — Handle the events
One endpoint handles all five events. Sync events return data; async events are acknowledged and processed in the background:
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.THRELL_WEBHOOK_SECRET; // whsec_...
function verify(rawBody, header) {
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(header || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-Threll-Signature'))) {
return res.status(401).end();
}
const { type, data } = JSON.parse(req.body);
switch (type) {
// ── Sync: Threll waits for these responses ──────────────
case 'call.worker_request':
// Inject fresh context just before the call starts
return res.json({
context: crm.latestNotesFor(data.customer.number),
});
case 'call.tool_call':
if (data.name === 'lookup_account') {
const account = crm.findAccount(data.arguments.accountNumber);
return res.json({ result: account ?? { error: 'not found' } });
}
return res.json({ result: { error: `unknown tool ${data.name}` } });
// ── Async: acknowledge fast, process in background ──────
case 'call.transcript':
res.status(200).end();
if (data.isFinal) liveFeed.push(data.callId, data.role, data.text);
return;
case 'call.status_update':
res.status(200).end();
crm.updateCallStatus(data.callId, data.status);
return;
case 'call.ended':
res.status(200).end();
finalizeCall(data.callId); // Step 4
return;
default:
return res.status(200).end();
}
});
app.listen(3000);Step 3 — Start the call
const call = await threll('/v1/accounts/9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b/phone-calls', {
method: 'POST',
body: JSON.stringify({
workerId: '2b7d9a4c-6e3f-48a1-9c5d-8e0b4f2a1c8d', // Harald
phoneNumber: '+15555550100',
context: 'Qualify this inbound demo request. Goal: book a meeting.',
customer: {
type: 'lead',
firstName: 'Kari',
lastName: 'Nordmann',
externalId: 'crm-8841',
},
}),
});
console.log('started', call.id); // events for call.id now flow to /webhookcurl -X POST https://api.threll.io/v1/accounts/9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b/phone-calls \
-H "x-api-key: $THRELL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workerId": "2b7d9a4c-6e3f-48a1-9c5d-8e0b4f2a1c8d",
"phoneNumber": "+15555550100",
"context": "Qualify this inbound demo request. Goal: book a meeting.",
"customer": { "type": "lead", "firstName": "Kari", "externalId": "crm-8841" }
}'During the call, your handler from Step 2 does the live work: it answers the call.worker_request, serves the lookup_account tool call, and streams transcript turns into your live feed.
Step 4 — Finalize when the call ends
call.ended is your signal to collect results. Post-processing may still be running when it arrives, so check postProcessingStatus before reading the transcript:
async function finalizeCall(callId) {
const base = `/v1/accounts/9c3e8f02-7a14-4b62-bc59-1d8e5fa3027b/phone-calls/${callId}`;
// Wait for post-processing (transcript + analysis) to finish
let call = await threll(base);
while (call.postProcessingStatus === 'in_progress' || call.postProcessingStatus === 'none') {
await new Promise(r => setTimeout(r, 5000));
call = await threll(base);
}
if (call.postProcessingStatus === 'failed') {
return crm.flagForReview(callId, 'post-processing failed');
}
const [transcript, audio] = await Promise.all([
threll(`${base}/transcript`),
threll(`${base}/audio/url`),
]);
await crm.logCall({
externalId: call.customer?.externalId,
duration: call.duration,
nextAction: call.nextAction, // e.g. 'book_meeting'
dimensions: call.dimensions, // e.g. { budget_range: '10000-50000' }
transcript: transcript.map(s => `[${s.speaker}] ${s.text}`).join('\n'),
recordingUrl: audio.url, // short-lived — download promptly
});
}The audio URL is a temporary download link. If you need the recording long-term, download it when you receive the URL rather than storing the link.
Production checklist
- Signature verification on every webhook request, over the raw body.
- Deduplication keyed on
X-Threll-Event-Id— retries can redeliver events. - Sync handlers respond in well under a second; the caller is waiting.
- Branch on the structured
400error types when creating calls (see handling errors). - Treat
call.endedas the only reliable "call is over" signal — not a transcript going quiet.