Skip to main content
🎥 Prefer to follow along? Watch the walkthrough for this guide here: https://youtu.be/UuoblYCPRl4.
Watch the walkthrough on YouTube

Bolna + Google Sheets: Call Reports and Triggers

End-to-end flow: set up a Google Sheet and webhook → connect that webhook to a Bolna agent → place a test call and watch execution data land in the sheet → optionally trigger new Bolna calls directly from the same sheet.

Prerequisites

Getting Bolna Calls Data in Google Sheet

Capture call execution data from Bolna by exposing a Google Apps Script Web App webhook and wiring it to your Bolna agent.
  1. Create the Google Sheet. Open https://sheets.new and name it something like Bolna Report. New Google Sheet named “Bolna Report”
  2. Open Apps Script. In the sheet, go to Extensions → Apps Script and optionally rename the project to Bolna Script. Open Apps Script from the Extensions menu
  3. Add the webhook Apps Script code. This script handles POST requests from Bolna and appends execution data into the sheet (generates a tab named Calls with columns for timestamp, call ID, status, toNumber, start and end times).
Apps Script editor with webhook handler code
For the exact payload structure from Bolna, refer to the executions API: https://www.bolna.ai/docs/api-reference/executions/get_execution.
/***********************
 * CONFIG
 ***********************/
const SHEET_NAME      = 'Calls';               // Change if you want another sheet name

// Columns we want to keep structured
const HEADER_NAMES = [
  'toNumber',                    // A – numbers to call (required)
  'Call Status',                 // e.g. queued, completed, failed
  'Execution ID',                // Bolna execution / call id
  'Conversation Time (sec)',     // conversation_time
  'Duration (sec)',              // telephony_data.duration
  'Total Cost',                  // total_cost
  'Answered by Voicemail',       // answered_by_voice_mail
  'Recording URL',               // telephony_data.recording_url
  'Hangup By',                   // telephony_data.hangup_by
  'Hangup Reason',               // telephony_data.hangup_reason
  'Transcript',                  // transcript (if provided)
  'Extracted Data (JSON)',       // extracted_data as JSON
  'Recipient Name',              // context_details.recipient_data.name
  'Recipient Email',             // context_details.recipient_data.email
  'Last Updated At'              // timestamp when we last wrote
];

/***********************
 * 1) WEBHOOK HANDLER
 *    (Bolna -> Sheet)
 ***********************/
function doPost(e) {
  const sheet = getOrCreateSheet_();
  const headersMap = ensureHeaders_(sheet);

  try {
    if (!e || !e.postData || !e.postData.contents) {
      return jsonResponse_({ ok: false, error: 'Empty request body' });
    }

    const rawBody = e.postData.contents;
    const payload = JSON.parse(rawBody);

    // Bolna sends an "execution payload" object when a phone call ends.
    // We try to be tolerant to slightly different shapes.
    const execution = extractExecutionFromWebhook_(payload);
    if (!execution) {
      return jsonResponse_({ ok: false, error: 'No execution object found in payload' });
    }

    const execId = execution.id || execution.execution_id || '';
    const telephony = execution.telephony_data || execution.transfer_call_data || {};
    const context = execution.context_details || {};
    const recipientData = context.recipient_data || {};

    const toNumber =
      telephony.to_number ||
      context.recipient_phone_number ||
      recipientData.phone ||
      '';
    const normalizedToNumber = normalizePhone_(toNumber);

    // Try to find the row: prefer Execution ID, fall back to toNumber
    let rowIndex = -1;
    if (execId && headersMap['Execution ID']) {
      rowIndex = findRowByValue_(sheet, headersMap['Execution ID'], execId);
    }
    if (rowIndex === -1 && normalizedToNumber && headersMap['toNumber']) {
      rowIndex = findRowByValue_(
        sheet,
        headersMap['toNumber'],
        normalizedToNumber,
        normalizePhone_
      );
    }

    // If nothing found, append a new row
    if (rowIndex === -1) {
      rowIndex = sheet.getLastRow() + 1;
      if (headersMap['toNumber'] && toNumber) {
        sheet.getRange(rowIndex, headersMap['toNumber']).setValue(toNumber);
      }
    }

    // Write all the pretty data into that row
    writeExecutionToRow_(
      sheet,
      headersMap,
      rowIndex,
      execution,
      telephony,
      context,
      recipientData,
      toNumber
    );

    return jsonResponse_({ ok: true });
  } catch (err) {
    Logger.log('Webhook error: ' + err + '\n' + (err.stack || ''));
    return jsonResponse_({ ok: false, error: String(err) });
  }
}

/***********************
 * Helpers
 ***********************/

function getOrCreateSheet_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
  }
  return sheet;
}

/**
 * Ensures header row exists and contains all HEADER_NAMES.
 * Returns a map { headerName: columnIndex }.
 * Guarantees col 1 header is exactly "toNumber".
 */
function ensureHeaders_(sheet) {
  let lastRow = sheet.getLastRow();
  let lastCol = sheet.getLastColumn();

  // Completely empty sheet – write all headers in one go
  if (lastRow === 0 || lastCol === 0) {
    sheet.getRange(1, 1, 1, HEADER_NAMES.length).setValues([HEADER_NAMES]);
  } else {
    const row = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
    const headerMap = {};

    for (let c = 0; c < row.length; c++) {
      const name = String(row[c] || '').trim();
      if (name) headerMap[name] = c + 1;
    }

    // Force first column header to be "toNumber"
    if (!row[0] || String(row[0]).trim() !== 'toNumber') {
      sheet.getRange(1, 1).setValue('toNumber');
      headerMap['toNumber'] = 1;
    }

    // Append missing headers to the right
    let col = lastCol + 1;
    HEADER_NAMES.forEach(function (name) {
      if (!headerMap[name]) {
        sheet.getRange(1, col).setValue(name);
        headerMap[name] = col;
        col++;
      }
    });

    return headerMap;
  }

  // After writing fresh headers, build map
  const headers = sheet
    .getRange(1, 1, 1, sheet.getLastColumn())
    .getValues()[0];

  const headerMap = {};
  for (let c = 0; c < headers.length; c++) {
    const name = String(headers[c] || '').trim();
    if (name) headerMap[name] = c + 1;
  }
  return headerMap;
}

/**
 * Find the first row (>=2) in column colIndex whose value matches the given value.
 * If normalizer is provided, compare normalized values (e.g., to align phone formats).
 * Returns row index or -1 if not found.
 */
function findRowByValue_(sheet, colIndex, value, normalizer) {
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return -1;

  const numRows = lastRow - 1;
  const values = sheet.getRange(2, colIndex, numRows, 1).getValues();
  const needle = normalizer
    ? normalizer(value || '')
    : String(value || '').trim();

  for (let i = 0; i < values.length; i++) {
    const cell = normalizer
      ? normalizer(values[i][0])
      : String(values[i][0] || '').trim();
    if (cell && cell === needle) {
      return i + 2; // actual sheet row
    }
  }
  return -1;
}

/**
 * Try to normalize different possible webhook payload shapes
 * into a single "execution" object.
 *
 * Bolna's execution objects typically contain fields like:
 *  id, agent_id, status, conversation_time, total_cost,
 *  telephony_data { duration, to_number, from_number, recording_url, hangup_by, hangup_reason, ... },
 *  extracted_data, context_details, etc. :contentReference[oaicite:3]{index=3}
 */
function extractExecutionFromWebhook_(payload) {
  if (!payload) return null;

  if (payload.execution) return payload.execution;

  if (Array.isArray(payload.data) && payload.data.length > 0) {
    return payload.data[0];
  }

  // Fall back to assuming payload itself IS the execution object
  return payload;
}

/**
 * Write execution data into a row, using the header map.
 */
function writeExecutionToRow_(sheet, headersMap, rowIndex,
                              execution, telephony, context,
                              recipientData, toNumber) {
  if (headersMap['toNumber'] && toNumber) {
    sheet.getRange(rowIndex, headersMap['toNumber']).setValue(toNumber);
  }

  if (headersMap['Call Status'] && execution.status) {
    sheet.getRange(rowIndex, headersMap['Call Status']).setValue(execution.status);
  }

  if (headersMap['Execution ID']) {
    const execId = execution.id || execution.execution_id || '';
    if (execId) {
      sheet.getRange(rowIndex, headersMap['Execution ID']).setValue(execId);
    }
  }

  if (headersMap['Conversation Time (sec)'] &&
      typeof execution.conversation_time !== 'undefined') {
    sheet.getRange(rowIndex, headersMap['Conversation Time (sec)'])
      .setValue(execution.conversation_time);
  }

  if (headersMap['Duration (sec)'] &&
      typeof telephony.duration !== 'undefined') {
    sheet.getRange(rowIndex, headersMap['Duration (sec)'])
      .setValue(telephony.duration);
  }

  if (headersMap['Total Cost'] &&
      typeof execution.total_cost !== 'undefined') {
    sheet.getRange(rowIndex, headersMap['Total Cost'])
      .setValue(execution.total_cost);
  }

  if (headersMap['Answered by Voicemail'] &&
      typeof execution.answered_by_voice_mail !== 'undefined') {
    sheet.getRange(rowIndex, headersMap['Answered by Voicemail'])
      .setValue(execution.answered_by_voice_mail);
  }

  if (headersMap['Recording URL'] && telephony.recording_url) {
    sheet.getRange(rowIndex, headersMap['Recording URL'])
      .setValue(telephony.recording_url);
  }

  if (headersMap['Hangup By'] && telephony.hangup_by) {
    sheet.getRange(rowIndex, headersMap['Hangup By'])
      .setValue(telephony.hangup_by);
  }

  if (headersMap['Hangup Reason'] && telephony.hangup_reason) {
    sheet.getRange(rowIndex, headersMap['Hangup Reason'])
      .setValue(telephony.hangup_reason);
  }

  if (headersMap['Transcript'] && execution.transcript) {
    sheet.getRange(rowIndex, headersMap['Transcript'])
      .setValue(execution.transcript);
  }

  if (headersMap['Extracted Data (JSON)'] && execution.extracted_data) {
    sheet.getRange(rowIndex, headersMap['Extracted Data (JSON)'])
      .setValue(JSON.stringify(execution.extracted_data));
  }

  if (headersMap['Recipient Name'] && recipientData.name) {
    sheet.getRange(rowIndex, headersMap['Recipient Name'])
      .setValue(recipientData.name);
  }

  if (headersMap['Recipient Email'] && recipientData.email) {
    sheet.getRange(rowIndex, headersMap['Recipient Email'])
      .setValue(recipientData.email);
  }

  if (headersMap['Last Updated At']) {
    sheet.getRange(rowIndex, headersMap['Last Updated At'])
      .setValue(new Date());
  }
}

/**
 * Simple JSON response helper for webhooks.
 */
function jsonResponse_(obj) {
  return ContentService
    .createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}
  1. Deploy as a Web App. Click Deploy → New deployment. Deploy script as Web App Select Web app as the deployment type. Choose Web App deployment type Use description “Web hook for Bolna calls,” set Execute as to Me, and Who has access to Anyone so Bolna can reach it. Configure Web App access Click Deploy, authorize when prompted, then copy the Web App URL for use in Bolna. Copy the Web App URL after deployment
Deploying the Web App may require granting the script permission to access your Google Sheet.
Treat the Web App URL as a secret webhook endpoint. Do not share it publicly.
  1. Connect the webhook in a Bolna agent. Log in to Bolna (https://platform.bolna.ai/login). Click New agent. Create a new Bolna agent Choose the prebuilt Announcement agent. Choose the Announcement prebuilt agent Open the Analytics tab. Open the Analytics tab Paste the Web App URL into Your webhook URL. Paste the Web App URL into the webhook field Click Save agent. Save the agent after adding the webhook
  2. Place a test call and verify data. Click Get call from agent. Start a test call from the agent In the Place outbound calls dialog, enter your phone number and click Place call. Enter your number and place the call
⚠️ Trial accounts can only make calls to verified phone numbers. Verify your number first at https://platform.bolna.ai/verified-phone-numbers before placing calls. Verified phone numbers page
If your number isn’t verified, you’ll see an error. Error shown when number is not verified After verification, place the call again; once the call completes, execution data will arrive automatically in your sheet. Google Sheet showing webhook data from Bolna

Triggering Bolna Calls from Google Sheet

Reuse the same spreadsheet to initiate new Bolna calls by reading phone numbers from a sheet column and calling Bolna’s API via Apps Script.
  1. Prepare the sheet for outbound calls. Add a new tab (e.g., Calls) with the first column titled toNumber and list the phone numbers to call. Sheet tab with toNumber column for outbound calls
  2. Get the Agent ID and API key from Bolna. In your agent, click Copy agent id. Copy the Bolna agent ID Go to the Developers tab. Open the Developers tab Create a new API key and copy it. Create and copy a Bolna API key
⚠️ Keep your Bolna API key secret. Do not share it or commit it to source control.
  1. Create the triggerCalls Apps Script file. In Apps Script, add a new file (e.g., triggerCalls.gs) and paste the function below. Replace the placeholders BOLNA_AGENT_IDand BOLNA_API_KEY with your values. Add a new Apps Script file for triggerCalls
/***********************
 * OUTBOUND CALLER
 *    triggerCalls()
 ***********************/
const BOLNA_API_KEY     = 'your-api-key';  // Provided Bolna key
const BOLNA_AGENT_ID    = 'your-agent-id'; // The agent that should place calls
const BOLNA_FROM_NUMBER = '';              // Optional: verified caller ID in E.164, or '' for default
const BOLNA_USER_DATA   = {                // Data to pass through to the agent
  variable1: 'value1',
  variable2: 'value2',
  variable3: 'some phrase as value'
};

/**
 * Iterate through rows, starting at row 2, taking the phone number
 * from the first column (header MUST be "toNumber"), validating,
 * and calling Bolna /call for each valid number.
 */
function triggerCalls() {
  const sheet = getOrCreateSheet_();
  const headersMap = ensureHeaders_(sheet);

  const toNumberCol    = headersMap['toNumber'];
  const statusCol      = headersMap['Call Status'];
  const execIdCol      = headersMap['Execution ID'];
  const lastUpdatedCol = headersMap['Last Updated At'];

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) {
    Logger.log('No data rows found');
    return;
  }

  const numRows = lastRow - 1;
  const toNumbers = sheet.getRange(2, toNumberCol, numRows, 1).getValues();

  for (let i = 0; i < numRows; i++) {
    const rowIndex = i + 2;
    const cellValue = toNumbers[i][0];

    if (!cellValue) continue;

    const phone = String(cellValue).trim();
    if (!phone) continue;

    // Skip already processed rows (you can tweak this condition)
    let currentStatus = statusCol ? sheet.getRange(rowIndex, statusCol).getValue() : '';

    // Validate E.164 format (e.g. +919876543210, +12025551234)
    if (!isValidE164Number_(phone)) {
      if (statusCol) {
        sheet.getRange(rowIndex, statusCol).setValue('invalid_number');
      }
      continue;
    }

    try {
      const result = makeBolnaCall_(phone); // calls /call

      if (statusCol) {
        sheet.getRange(rowIndex, statusCol).setValue(result.status || 'queued');
      }
      if (execIdCol) {
        const execId = result.execution_id || result.run_id;
        if (execId) {
          sheet.getRange(rowIndex, execIdCol).setValue(execId);
        }
      }
      if (lastUpdatedCol) {
        sheet.getRange(rowIndex, lastUpdatedCol).setValue(new Date());
      }
    } catch (err) {
      Logger.log('Error placing call for row ' + rowIndex + ': ' + err);
      if (statusCol) {
        sheet.getRange(rowIndex, statusCol).setValue('error');
      }
      const transcriptCol = headersMap['Transcript'];
      if (transcriptCol) {
        sheet.getRange(rowIndex, transcriptCol).setValue(String(err));
      }
    }
  }
}

/**
 * Actually hits Bolna's /call API.
 * Endpoint: POST https://api.bolna.ai/call with JSON body
 *   { agent_id, recipient_phone_number, user_data?, from_phone_number? }
 * Auth: Authorization: Bearer <token>
 */
function makeBolnaCall_(phoneNumber) {
  const url = 'https://api.bolna.ai/call';

  const payload = {
    agent_id: BOLNA_AGENT_ID,
    recipient_phone_number: phoneNumber,
    user_data: BOLNA_USER_DATA
  };

  if (BOLNA_FROM_NUMBER) {
    payload.from_phone_number = BOLNA_FROM_NUMBER;
  }

  const options = {
    method: 'post',
    muteHttpExceptions: true,
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + BOLNA_API_KEY
    },
    payload: JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(url, options);
  const statusCode = response.getResponseCode();
  const bodyText = response.getContentText();

  if (statusCode < 200 || statusCode >= 300) {
    throw new Error('Bolna /call failed (' + statusCode + '): ' + bodyText);
  }

  // Expected response: { message: "done", status: "queued", execution_id: "...", run_id: "..." }
  const data = JSON.parse(bodyText);
  return data;
}

/**
 * Very basic E.164 validation: +countrycode + 6–14 more digits.
 * Required by Bolna's API for phone numbers. :contentReference[oaicite:2]{index=2}
 */
function isValidE164Number_(value) {
  const str = String(value || '').trim();
  return /^\+[1-9]\d{6,14}$/.test(str);
}

/**
 * Normalize phone numbers so different formats map to the same comparable value.
 * Removes every non-digit character.
 */
function normalizePhone_(value) {
  return String(value || '').replace(/[^\d]/g, '');
}
  1. Run the function manually (first test). In Apps Script, select triggerCalls and click Run. Authorize if prompted. Bolna will call the numbers listed in toNumber, and the script writes call status and execution ID.
  2. Optional: Add a button in the sheet to trigger calls. In the sheet, go to Insert → Drawing. Insert a drawing to create a button Draw a small rectangle to serve as a button. Draw a rectangle button Click the drawing’s three-dots menu and choose Assign script. Assign a script to the drawing Enter triggerCalls as the function name. Enter the triggerCalls function name Clicking the button now runs triggerCalls and starts calls for the numbers in the sheet.
The button is a simple convenience. You can also add custom menus or time-based triggers if you prefer.
You can now view inbound call execution reports in Google Sheets and launch new Bolna calls directly from the same Sheet.