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 core event types​

These 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 an earlier event for logging. Subscribe to call.initiated or call.incoming as well 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, failed, or voicemail)A row in your call log — timestamps, duration, direction, status
voicemail.newA voicemail is leftA row in your voicemail log with the caller info
recording.availableA call recording finishesA recording flag on 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
recording.deleted / recording.transcript.deletedThe platform deleted a call's audio or transcriptRemove your copy; the call log row stays

See Webhook Events for every field in every payload. The payloads carry identifiers, not content: fetch the audio and transcripts from the API when you need them.

Minimal handler​

TypeScript
import { DialStack } from '@dialstack/sdk-server';

const dialstack = new DialStack(process.env.DIALSTACK_API_KEY);

// POST /webhooks/dialstack
app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => {
const event = DialStack.webhooks.constructEvent(
req.body,
req.header('x-dialstack-signature'),
process.env.DIALSTACK_WEBHOOK_SECRET
); // throws on a bad signature; see receiving-webhooks.md

// 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();
}
// Every call write is an upsert keyed on call_id: the events for one call
// are not ordered against each other, so the row may not exist yet.
switch (event.type) {
case 'call.end':
await db.calls.upsert({
call_id: event.data.call_id,
account: event.account,
direction: event.data.direction,
from: event.data.from_number,
to: event.data.to_number,
user: event.data.user,
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). 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: event.account,
recording_duration_seconds: event.data.duration_seconds,
has_recording: true,
});
break;

case 'recording.transcription.complete': {
const transcript = await dialstack.calls.retrieveTranscript(event.data.call_id, {
dialstackAccount: event.account,
});
await db.calls.upsert({ call_id: event.data.call_id, account: event.account, transcript });
break;
}

case 'recording.deleted':
// The platform deleted the audio. Drop anything you stored from it.
await db.calls.upsert({
call_id: event.data.call_id,
account: event.account,
has_recording: false,
});
break;

case 'recording.transcript.deleted':
await db.calls.upsert({
call_id: event.data.call_id,
account: event.account,
transcript: null,
});
break;

case 'voicemail.new':
await db.voicemails.upsert({
voicemail: event.data.voicemail,
account: event.account,
call_id: event.data.call_id,
from: event.data.from_number,
received_at: event.data.created_at,
});
break;

case 'voicemail.transcription.complete': {
const transcript = await dialstack.voicemails.retrieveTranscript(event.data.voicemail, {
dialstackAccount: event.account,
});
await db.voicemails.upsert({ voicemail: event.data.voicemail, transcript });
break;
}
}

// Mark the event processed and acknowledge only once the writes succeeded:
// DialStack doesn't redeliver an event that got a 2xx.
await db.processedEvents.insert({ id: event.id });
res.status(200).end();
});

Acknowledge only after your writes succeed. If persisting is slow, put the event on a durable queue first and respond once it is queued. Retries, timeouts and delivery order are described in Receiving Webhooks: dedupe on event.id so a retried event doesn't produce a duplicate row.

Enrichment window​

call.end fires when the call ends. Recordings and transcripts key off the recording, not the call, so neither is ordered against call.end and either can arrive before it. A recording covers the conversation, and a call can outlive its conversation — a call that reaches voicemail is the common example. See Ordering and Timing.

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​