Skip to main content

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).

EventWhen it firesWhat to persist
call.endCall completes (answered, no-answer, busy, or failed)A row in your call log — timestamps, duration, direction, status
voicemail.newA voicemail is leftA row in your voicemail log with the audio URL and caller info
recording.availableA call recording finishesThe recording, attached to the call log row for that call_id
recording.transcription.complete / voicemail.transcription.completeTranscripts become readyThe transcript text, attached to the call or voicemail row

See Webhook Events for every field in every payload.

Minimal handler

TypeScript
// 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.available can arrive after recording.transcription.complete if 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 before call.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 before call.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