Activity Logging
Write every call, voicemail, and recording into your system of record.
This is a pattern built on Webhook Events, not a separate API — webhooks are the right transport because they give you durable retries, signed payloads, and stable event IDs. The pattern is the same regardless of your stack: receive → dedupe → write.
The four primary event types
These four cover the minimum needed to reconstruct a call log. call.end carries everything you need to write the row — direction, timestamps, duration, status, caller/callee — so you don't need call.start for logging. Subscribe to call.start or call.incoming additionally if you want a pre-answer row (e.g. to show ringing calls in a live dashboard).
| Event | When it fires | What to persist |
|---|---|---|
call.end | Call completes (answered, no-answer, busy, or failed) | A row in your call log — timestamps, duration, direction, status |
voicemail.new | A voicemail is left | A row in your voicemail log with the audio URL and caller info |
recording.available | A call recording finishes | The recording, attached to the call log row for that call_id |
recording.transcription.complete / voicemail.transcription.complete | Transcripts become ready | The transcript text, attached to the call or voicemail row |
See Webhook Events for every field in every payload.
Minimal handler
// POST /webhooks/dialstack
app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => {
verifySignature(req); // see webhook-events.md
const event = JSON.parse(req.body);
// Dedupe by event.id — DialStack may deliver the same event more than once.
if (await db.processedEvents.exists(event.id)) {
return res.status(200).end();
}
await db.processedEvents.insert({ id: event.id });
switch (event.type) {
case 'call.end':
await db.calls.upsert({
call_id: event.data.call_id,
account_id: event.account_id,
direction: event.data.direction,
from: event.data.from_number,
to: event.data.to_number,
user_id: event.data.user_id,
status: event.data.status,
started_at: event.data.started_at,
answered_at: event.data.answered_at,
ended_at: event.data.ended_at,
duration_seconds: event.data.duration_seconds,
});
break;
case 'recording.available':
// May arrive BEFORE call.end (e.g. calls that reach voicemail), so
// upsert rather than update — the row may not exist yet. The payload
// carries no URL; fetch the audio from the API when you need it.
await db.calls.upsert({
call_id: event.data.call_id,
account_id: event.account_id,
recording_duration_seconds: event.data.duration_seconds,
has_recording: true,
});
break;
case 'recording.transcription.complete':
await db.calls.update(event.data.call_id, {
transcript: event.data.transcript,
});
break;
case 'voicemail.new':
await db.voicemails.insert({
voicemail_id: event.data.voicemail_id,
account_id: event.account_id,
from: event.data.from_number,
audio_url: event.data.url,
received_at: event.created_at,
});
break;
case 'voicemail.transcription.complete':
await db.voicemails.update(event.data.voicemail_id, {
transcript: event.data.transcript,
});
break;
}
res.status(200).end();
});
Respond 200 fast. If any persist step is slow, queue it (SQS, BullMQ) and let the webhook return immediately — DialStack will treat anything slower than a few seconds as a failure and retry.
Retry behavior
DialStack retries non-2xx responses with exponential backoff. That means:
- Dedupe on
event.id. Every event carries a stable ID. A retry of the same event reuses that ID, so if you key your writes on it (or record the IDs you've already processed), retries won't produce duplicate rows. - Accept out-of-order delivery.
recording.availablecan arrive afterrecording.transcription.completeif the transcript came from an earlier attempt. Persist what you have, enrich what comes later. - Short timeouts. DialStack's own timeout is tight; don't make upstream calls on the webhook path.
Enrichment window
call.end fires immediately when the call ends. Recordings and transcripts are produced by separate pipelines that key off the recording, not the call, so neither is ordered against call.end and either can arrive before it:
recording.available— usually within seconds of the recording finishing, which is not necessarily when the call ends. A recording covers the conversation, and a call can outlive its conversation — a call that reaches voicemail is the common example — so the event can arrive well beforecall.end— see Ordering.*.transcription.complete— typically under a minute after the recording finishes; longer for long calls. Transcription starts from the finished recording, so it can also complete beforecall.end.
Your UI should show the call row as soon as it has one — usually on call.end, but on recording.available if that arrives first — and progressively enrich it as the other events arrive. Write all of these with an upsert keyed on call_id rather than an update: an update would silently match zero rows, and a deduped event is never redelivered.
See also
- Webhook Events — full event surface + signature verification.
- Screen Pop — the other half of the event loop: react to calls as they ring.
- Appointment Webhooks — request/response webhooks for AI Scheduling.