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).
| Event | When it fires | What to persist |
|---|---|---|
call.end | Call completes (answered, no-answer, busy, failed, or voicemail) | A row in your call log — timestamps, duration, direction, status |
voicemail.new | A voicemail is left | A row in your voicemail log with the caller info |
recording.available | A call recording finishes | A recording flag on 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 |
recording.deleted / recording.transcript.deleted | The platform deleted a call's audio or transcript | Remove 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
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
- Webhook Events — every event and payload.
- Receiving Webhooks — signature verification, retries, ordering.
- Screen Pop — the other half of the event loop: react to calls as they ring.
- AI Scheduling — request/response webhooks for appointment booking.