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 URL, attached to the existing call log row
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

// 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':
await db.calls.update(event.data.call_id, {
recording_url: event.data.url,
});
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 arrive later:

  • recording.available — seconds to a minute after call.end.
  • *.transcription.complete — typically under a minute for short calls; longer for long calls.

Your UI should show the call row immediately on call.end and progressively enrich it as the other events arrive.

See also