How to work with call data

Getting started

This guide walk you through the two main methods you can use to get Aircall call data and gives you some pointers to work with that data. Before you start, make sure you have:

  1. You must have an Aircall account. Sign up as a customer here or if you qualify to be a tech partner you can then sign up for a developer account here.
  2. A registered app with a valid OAuth access token.
  3. A publicly accessible HTTPS endpoint to receive webhook events.

Real-time sync (Webhooks)

With real-time synchronization, your app receives call data as soon as something happens in Aircall. You can keep your CRM, helpdesk, analytics platform, data warehouse, or internal system up to date without waiting for a scheduled import.

Real-time synchronization works with webhooks. Aircall sends webhook events to your app at different stages of the call lifecycle, such as when a call is created, answered, ended, tagged, or commented on.

This is the easiest and most scalable way to sync call data from Aircall. Because your app reacts to webhook events, you do not need to manage REST API rate limits, pagination, or scheduled polling jobs for day-to-day synchronization.

Below we add an example but it is recommended to check our webhook guides to understand how they work in depth.

1. Setup Webhooks

To receive these events, set up a web server with one publicly accessible HTTPS POST endpoint. Your app can process each event as it arrives and update your own records, trigger workflows, or store call metadata. Example:

const createWebhook = async (accessToken) => {
  try {
    const response = await fetch('https://api.aircall.io/v1/webhooks', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        custom_name: 'YOUR_APP_NAME',
        url: 'https://your-app.example.com/aircall/webhook',
        events: [
          'call.created', 'call.ringing_on_agent', 'call.agent_declined',
          'call.answered', 'call.transferred', 'call.hungup',
          'call.ended', 'call.voicemail_left', 'call.assigned',
          'call.archived', 'call.tagged', 'call.commented'
        ]
      })
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.message || 'Failed to create webhook');
    }

    // Store data.webhook.token against your user record.
    // You'll need it to verify incoming webhook signatures.
    return data.webhook.token;
  } catch (err) {
    console.error('Failed to create webhook:', err.message);
    throw err;
  }
};
📘

Save the webhook token. The response includes a token field. Store it against your user record — it identifies which Aircall account the events belong to.

2. Handle events

Set up a POST endpoint that accepts webhook payloads. Respond with 200 as quickly as possible — run any heavy processing asynchronously so Aircall doesn't time out waiting for a response.

const express = require('express');
const app = express();
app.use(express.json());

app.post('/aircall/webhook', (req, res) => {
  // Always respond 200 immediately.
  res.sendStatus(200);

  const { event, data } = req.body;

  // Process asynchronously to avoid timeouts.
  processCallEvent(event, data).catch(console.error);
});

const processCallEvent = async (event, callData) => {
  switch (event) {
    case 'call.ended':
      // Full call data is available — sync to CRM, trigger AI enrichment, etc.
      await upsertCallRecord(callData);
      break;

    case 'call.tagged':
    case 'call.commented':
      // Update tags or notes on an existing call record.
      await updateCallRecord(callData.id, callData);
      break;

    default:
      // Store all other events for audit purposes.
      await logEvent(event, callData);
  }
};

const upsertCallRecord = async (callData) => {
  // TODO: insert or update a call record using callData.id as the primary key.
  // Include tags and comments — agents submit these during or after the call.
};

const updateCallRecord = async (callId, callData) => { ... }
const logEvent = async (event, callData) => { ... }

app.listen(3000);
🚧

Use call.id as your primary key. Multiple events fire for a single call. Use upsert logic keyed on call.id — not insert — to avoid duplicate records.

Batch sync (Polling)

With batch synchronization, your app pulls call records from Aircall in pages using the REST API. This lets you import historical calls, backfill missing records, or reconcile your database with Aircall when you need a complete snapshot over a specific time range.

This approach is more limited than real-time synchronization. At the time of writing, paginated retrieval is capped at 10,000 calls, and your integration needs to handle pagination, API rate limits, retries, and large dataset imports.

Batch sync is still useful, but you should account for that complexity when designing it. For large imports, split requests into smaller date ranges, follow pagination links until there are no more pages, and add delays or retry logic to stay within API limits.

For endpoint details, parameters, and response fields, see the GET /v1/calls API reference.

Example API call with pagination and rate limit

/**
 * Fetches all calls, iterating through every page.
 * Adds a 1 second delay between requests to respect rate limits.
 *
 * @param {string} accessToken
 * @param {Object} [params]     - Optional query params (e.g. { from, to, order })
 * @returns {Promise<Array>}
 */
const fetchAllCalls = async (accessToken, params = {}) => {
  const headers = {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  };

  let url = new URL('https://api.aircall.io/v1/calls');
  url.search = new URLSearchParams({ per_page: 50, order: 'desc', ...params });

  const calls = [];

  while (url) {
    const response = await fetch(url, { headers });
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.message || 'Failed to fetch calls');
    }

    calls.push(...data.calls);
    url = data.meta?.next_page_link ? new URL(data.meta.next_page_link) : null;

    if (url) await delay(1000); // avoid hitting the 60 req/min rate limit
  }

  return calls;
};

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

Working with call data

A few fields in the call object need specific handling.

Filter by date range

Use the from and to query parameters (Unix timestamps) to limit results to a specific window. This also helps you work around the 10,000 call pagination limit — break large imports into smaller date windows.

// Fetch all calls from the past 7 days
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgo = now - (7 * 24 * 60 * 60);

const calls = await fetchAllCalls(accessToken, {
  from: sevenDaysAgo,
  to: now
});

Detecting missed calls

There's no single type: "missed" field on the call object. Check status and missed_call_reason together:

const isMissed = (call) =>
  call.status === 'done' && call.missed_call_reason != null;

// Common values of missed_call_reason:
// 'out_of_opening_hours', 'short_abandoned', 'abandoned_in_ivr', 'no_available_agent'

Calculating duration

The duration field covers the full call lifecycle, from creation to end. If you need the time the agent was actually on the call, calculate it from answered_at.

// Total duration (ring time + talk time + hold time)
const totalDuration = call.ended_at - call.started_at; // seconds

// In-call (talk) duration only — null for unanswered calls
const talkDuration = call.answered_at
  ? call.ended_at - call.answered_at
  : null;

Getting the real waiting time and call path

Use the query param fetch_call_timeline set to true on the Call Search endpoint to get the IVR options including timestamps the call went through to calculate true waiting time and being able to split calls into branches on the same phone number.

Handling recordings and voicemails

You have four fields with a signed URL to an MP3 returned in the call object:

  • recording expires in 1 hour
  • voicemail expires in 1 hour
  • recording_short_urlexpires in 3 hours
  • voicemail_short_url expires in 3 hours

The recording field contains a signed URL to an MP3 file. The URL is valid for the time mentioned above after it is generated.

If your integration needs to archive recordings, download the file as soon as you receive the call.comm_asset_generated event and store it in your own infrastructure.

const fs = require('fs');
const { Readable } = require('stream');
const { pipeline } = require('stream/promises');

/**
 * Downloads a call recording and saves it to the local filesystem.
 * Call this immediately on receiving a call.ended event.
 */
const downloadRecording = async (call) => {
  if (!call.recording) return null;

  const response = await fetch(call.recording);

  if (!response.ok) {
    throw new Error(`Failed to download recording: ${response.status}`);
  }

  const dest = `./recordings/${call.id}.mp3`;
  const writer = fs.createWriteStream(dest);

  await pipeline(Readable.fromWeb(response.body), writer);

  return dest;
};

If you don't want to store them you can use the asset field which has a static link to our dashboard assets page and the user can be redirected there to listen to the recording, voicemails and see transcripts and other call data.


What’s Next

And that’s it! You are now logging call data to your database. Here are a few things that you can explore next:

Did this page help you?