Vibha's data and monitoring platform · vibha.org

Connect Looker Studio

Turn one form's responses into free, shareable charts — total responses, a trend by month, an average score by group — that open in any web browser, on a phone, a Chromebook, or a computer, for anyone with a Google account. Nothing to install, and it never costs anything no matter how many people you share it with. Only safe, summary numbers ever leave the system this way — never a person's name, email, or written answer.

What Looker Studio actually is, in plain terms

Looker Studiois a free tool from Google for turning numbers into charts and dashboards — think of it as Google's own version of Power BI, except it lives entirely in your web browser. There is nothing to download or install, and because Vibha already uses Google Workspace, everyone on the team already has the account it needs to open one.

This page connects it to one form's responses through a small, safe “go-between” you set up once. That go-between only ever hands over grouped totals — “312 people answered Yes this month” — never a single response, name, or written answer. It is built to be shared widely: with a state coordinator, a donor, or a government partner, the same way you might share a read-only report.

Power BI or Looker Studio — which one do you want?

Power BILooker Studio
CostFree to build, free to viewFree to build and free for every viewer
Works onWindows computers onlyAny device with a web browser — phone, Chromebook, Mac, Windows
Who can open itWhoever you send the file to, or invite to a paid workspaceAnyone with a Google account you share it with
What it can showEvery response, question by question, row by rowGrouped totals and averages only — no individual rows
Best forYour own team, digging into the full detail on a Windows PCA wide audience — a program team, a partner, a donor — on any device

What you need before you start

  • A Google account under Vibha's Google Workspace (not a personal Gmail address) — ask whoever manages Vibha's Google account if you are not sure you have one.
  • Editor or Admin access to the form whose responses you want to chart.
  • About fifteen minutes. This is a one-time setup, usually done by whoever on the team is comfortable copy-pasting into a webpage — once it's done, anyone can open the finished chart in seconds.

Step 1 — Get an API key

An API keyis a password-like code that proves Looker Studio is allowed to read your organization's numbers — and only the numbers, nothing else.

  1. 1Go to Integrations and click Create API key.
  2. 2Name it something recognizable, like “Looker Studio.”
  3. 3Tick exactly two boxes: submissions:aggregate and forms:read. The first is what keeps this key to grouped totals only — it physically cannot return a single response, even by mistake. The second only lets it read question wording, so your chart shows “Reading level” instead of a meaningless code.
  4. 4Copy the key the moment it's shown — it starts with fbk_ and is shown exactly once.

Step 2 — Find three IDs

The setup below needs the same three plain pieces of text as the Power BI connector:

WhatWhere to find it
Your deployment addressThe web address you normally use to open this app, with /v1 added to the end.
Your organization IDSettings → Organization — copy button next to Organization ID.
This form’s IDOpen the form and look in your browser’s address bar — the long id right after "/forms/".

Step 3 — Build the go-between, in Google Apps Script

Apps Scriptis a free Google tool for writing a small helper program that runs inside your Google account — this is where the “go-between” from earlier actually lives. You are not writing any code yourself; you are pasting two ready-made pieces of text into it, unchanged.

  1. 1Signed in to a Vibha Google Workspace account, go to script.google.com and click New project.
  2. 2Delete everything in the code box that opens, and paste in the first script below.
  3. 3Click the gear icon (Project Settings), tick “Show ‘appsscript.json’ manifest file in editor,” then open that file and paste in the second block below, replacing everything there.
  4. 4Save the project (the disk icon, or Ctrl/Cmd + S).
Code.gs — paste this into the code box
var cc = DataStudioApp.createCommunityConnector();
var ALLOWED_GROUPBY_TYPES = ['SINGLE_CHOICE', 'DROPDOWN', 'STAR_RATING', 'NPS', 'SLIDER'];
var ALLOWED_METRIC_TYPES = ['NUMBER', 'STAR_RATING', 'NPS', 'SLIDER'];
var FIXED_DIMENSIONS = { dimStatus: 'status', dimDay: 'day', dimWeek: 'week', dimMonth: 'month', dimYear: 'year' };

function getConfig(request) {
  var config = cc.getConfig();
  config.newInfo().setId('instructions').setText(
    'Shows a form\'s response numbers -- grouped counts and averages only, never a single answer.'
  );
  config.newTextInput().setId('baseUrl').setName('API base URL')
    .setPlaceholder('https://YOUR-DEPLOYMENT-HOST/v1');
  config.newTextInput().setId('orgId').setName('Organization ID');
  config.newTextInput().setId('formId').setName('Form ID');
  config.newTextInput().setId('additionalFormIds')
    .setName('Other form IDs to combine with it (optional, comma-separated)');
  config.newSelectSingle().setId('dateField').setName('Count by, when splitting by Day/Week/Month/Year')
    .addOption(config.newOptionBuilder().setLabel('When it was submitted (default)').setValue('submittedAt'))
    .addOption(config.newOptionBuilder().setLabel('When it happened').setValue('occurredAt'));
  config.setDateRangeRequired(false);
  return config.build();
}

function getAuthType() {
  return cc.newAuthTypeResponse().setAuthType(cc.AuthType.KEY).build();
}
function isAuthValid() {
  return !!PropertiesService.getUserProperties().getProperty('dscc.key');
}
function resetAuth() {
  PropertiesService.getUserProperties().deleteProperty('dscc.key');
}
function setCredentials(request) {
  var key = request.key;
  if (!key || key.indexOf('fbk_') !== 0) return { errorCode: 'INVALID_CREDENTIALS' };
  PropertiesService.getUserProperties().setProperty('dscc.key', key);
  return { errorCode: 'NONE' };
}
function isAdminUser() { return false; }
function isMultiForm_(p) { return !!(p.additionalFormIds && p.additionalFormIds.trim()); }

// Auto-discovers every eligible dimension/metric from the form's own
// /bi/schema -- nothing typed in by question id. Multi-form mode exposes one
// Count metric per included form instead (see the plan doc for why).
function getFields_(request) {
  var p = (request && request.configParams) || {};
  var fields = cc.getFields();
  fields.newDimension().setId('dimStatus').setName('Status').setType(cc.FieldType.TEXT);
  fields.newDimension().setId('dimDay').setName('Day').setType(cc.FieldType.TEXT);
  fields.newDimension().setId('dimWeek').setName('Week (starting)').setType(cc.FieldType.TEXT);
  fields.newDimension().setId('dimMonth').setName('Month').setType(cc.FieldType.TEXT);
  fields.newDimension().setId('dimYear').setName('Year').setType(cc.FieldType.TEXT);
  fields.newMetric().setId('count').setName('Count').setType(cc.FieldType.NUMBER).setAggregation(cc.AggregationType.SUM);

  if (!isMultiForm_(p)) {
    try {
      var schema = callApi_(p, 'organizations/' + p.orgId + '/forms/' + p.formId + '/bi/schema', {});
      (schema.questions || []).forEach(function (q) {
        if (ALLOWED_GROUPBY_TYPES.indexOf(q.type) !== -1) {
          fields.newDimension().setId('dim_' + q.id).setName(q.label).setType(cc.FieldType.TEXT);
        }
        if (ALLOWED_METRIC_TYPES.indexOf(q.type) !== -1) {
          fields.newMetric().setId('avg_' + q.id).setName('Average — ' + q.label)
            .setType(cc.FieldType.NUMBER).setAggregation(cc.AggregationType.AVG);
        }
      });
    } catch (e) { /* fixed fields above still work */ }
  } else {
    [p.formId].concat(p.additionalFormIds.split(',')).forEach(function (formId) {
      formId = formId.trim();
      if (!formId) return;
      var title = null;
      try { title = callApi_(p, 'organizations/' + p.orgId + '/forms/' + formId, {}).title; } catch (e) {}
      fields.newMetric().setId('form_' + formId).setName('Count — ' + (title || formId))
        .setType(cc.FieldType.NUMBER).setAggregation(cc.AggregationType.SUM);
    });
  }
  return fields;
}

function getSchema(request) {
  return { schema: getFields_(request).build() };
}

function isDimension_(id) { return FIXED_DIMENSIONS.hasOwnProperty(id) || id.indexOf('dim_') === 0; }
function toGroupBy_(id) { return FIXED_DIMENSIONS[id] || 'question:' + id.slice(4); }

function getData(request) {
  var p = request.configParams || {};
  var requestedIds = request.fields.map(function (f) { return f.name; });
  var dims = requestedIds.filter(isDimension_);
  var metrics = requestedIds.filter(function (id) { return !isDimension_(id); });

  if (dims.length > 1) {
    cc.newUserError().setText(
      'One chart can use only one dimension from this connector at a time -- ' +
      'use Looker Studio\'s "Blend data" feature to combine two.'
    ).throwException();
  }
  var singleTotal = dims.length === 0; // e.g. a Scorecard, no dimension picked
  var groupBy = singleTotal ? 'status' : toGroupBy_(dims[0]);

  var rows = isMultiForm_(p)
    ? multiFormRows_(p, groupBy, metrics, singleTotal, requestedIds)
    : singleFormRows_(p, groupBy, metrics, singleTotal, requestedIds);

  return { schema: getFields_(request).forIds(requestedIds).build(), rows: rows };
}

function singleFormRows_(p, groupBy, metrics, singleTotal, requestedIds) {
  var avgIds = metrics.filter(function (id) { return id.indexOf('avg_') === 0; });
  var needsCount = metrics.indexOf('count') !== -1 || singleTotal;
  var byKey = {};

  if (avgIds.length > 0) {
    avgIds.forEach(function (metricId) {
      var qId = metricId.slice(4);
      var result = callApi_(p, 'organizations/' + p.orgId + '/forms/' + p.formId + '/submissions/aggregate',
        { groupBy: groupBy, metric: 'avg:' + qId, dateField: p.dateField });
      (result.buckets || []).forEach(function (b) {
        var e = byKey[b.key] || (byKey[b.key] = { key: b.key, count: 0, avg: {} });
        e.count = b.count;
        e.avg[metricId] = typeof b.avg === 'number' ? b.avg : null;
      });
    });
  } else if (needsCount) {
    var result = callApi_(p, 'organizations/' + p.orgId + '/forms/' + p.formId + '/submissions/aggregate',
      { groupBy: groupBy, metric: 'count', dateField: p.dateField });
    (result.buckets || []).forEach(function (b) { byKey[b.key] = { key: b.key, count: b.count, avg: {} }; });
  }

  var buckets = Object.keys(byKey).map(function (k) { return byKey[k]; });
  if (singleTotal) buckets = [collapse_(buckets, avgIds)];

  return buckets.map(function (bucket) {
    return { values: requestedIds.map(function (id) {
      if (id === 'count') return bucket.count;
      if (id.indexOf('avg_') === 0) return bucket.avg[id];
      return bucket.key;
    }) };
  });
}

function multiFormRows_(p, groupBy, metrics, singleTotal, requestedIds) {
  var forms = [p.formId].concat(p.additionalFormIds.split(',').map(function (s) { return s.trim(); })).filter(Boolean);
  var result = callApi_(p, 'organizations/' + p.orgId + '/submissions/rollup',
    { forms: forms.join(','), groupBy: groupBy, metric: 'count', dateField: p.dateField });
  var buckets = (result.buckets || []).map(function (b) { return { key: b.key, count: b.count, byForm: b.byForm || {} }; });

  if (singleTotal) {
    var total = 0, byForm = {};
    buckets.forEach(function (b) {
      total += b.count;
      Object.keys(b.byForm).forEach(function (f) { byForm[f] = (byForm[f] || 0) + b.byForm[f]; });
    });
    buckets = [{ key: null, count: total, byForm: byForm }];
  }

  return buckets.map(function (bucket) {
    return { values: requestedIds.map(function (id) {
      if (id === 'count') return bucket.count;
      if (id.indexOf('form_') === 0) return bucket.byForm[id.slice(5)] || 0;
      return bucket.key;
    }) };
  });
}

// Collapses every bucket into one grand total (for a Scorecard's no-dimension
// request), weighting an average by each bucket's own count.
function collapse_(buckets, avgIds) {
  var total = 0, sums = {};
  buckets.forEach(function (b) {
    total += b.count;
    avgIds.forEach(function (id) {
      var v = b.avg[id];
      if (typeof v === 'number') {
        var s = sums[id] || (sums[id] = { sum: 0, n: 0 });
        s.sum += v * b.count; s.n += b.count;
      }
    });
  });
  var avg = {};
  avgIds.forEach(function (id) { var s = sums[id]; avg[id] = (s && s.n > 0) ? s.sum / s.n : null; });
  return { key: null, count: total, avg: avg };
}

function callApi_(p, path, query) {
  var baseUrl = (p.baseUrl || '').replace(/\/+$/, '');
  var apiKey = PropertiesService.getUserProperties().getProperty('dscc.key');
  if (!baseUrl || !p.orgId || !p.formId || !apiKey) {
    cc.newUserError().setText('Check this connector\'s API base URL, Organization ID, and Form ID, and that it is authorized.').throwException();
  }
  var qs = Object.keys(query || {}).filter(function (k) { return query[k]; })
    .map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(query[k]); }).join('&');
  var response = UrlFetchApp.fetch(baseUrl + '/' + path + (qs ? '?' + qs : ''), {
    method: 'get', headers: { 'X-API-Key': apiKey }, muteHttpExceptions: true,
  });
  var status = response.getResponseCode();
  var body = {};
  try { body = JSON.parse(response.getContentText()); } catch (e) {}
  if (status < 200 || status >= 300) {
    cc.newUserError().setText('Request failed (' + status + '): ' + (body.message || response.getContentText())).throwException();
  }
  return body.data;
}
appsscript.json — paste this into the manifest file
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": ["https://www.googleapis.com/auth/script.external_request"],
  "dataStudio": {
    "name": "Vibha Form Submissions (Aggregate)",
    "logoUrl": "https://www.gstatic.com/images/branding/product/2x/data_studio_64dp.png",
    "company": "Vibha",
    "companyUrl": "https://vibha.org",
    "addOnUrl": "https://vibha.org",
    "supportUrl": "https://vibha.org",
    "description": "Grouped counts/averages from one or more forms' responses -- never a raw row.",
    "sources": ["VIBHA_FORM_SUBMISSIONS_AGGREGATE"]
  }
}

Step 4 — Turn it into something Looker Studio can connect to

  1. 1In the Apps Script editor, click Deploy → Test deployments.
  2. 2Click the gear icon next to “Select type” and choose Data Studio— that is Looker Studio's older name inside Google's own tools, so don't worry that it looks unfamiliar.
  3. 3Click Install, then copy the Head Deployment ID (or Deployment ID — the same value) shown in the box that appears.
  4. 4Build this address, with your copied id pasted in: https://datastudio.google.com/datasources/create?connectorId=<your id>.

Step 5 — Connect Looker Studio and build a chart

  1. 1Open the address you built in Step 4 in a new browser tab, signed in with the same account.
  2. 2When asked to authorize, paste in the API key from Step 1. Looker Studio never stores this key itself — only the go-between script you just set up ever sees it.
  3. 3Fill in the setup screen: your deployment address, organization ID, form ID, and (optional) other form IDs to combine with it. Leave everything else on its default — every chart in the report picks its own breakdown later, on the report canvas itself, not here.
  4. 4Click Connect, then Create Report. Looker Studio shows every available field — Status, Day, Week, Month, Year, Count, and one field per eligible question on the form — ready to drag onto a chart.
  5. 5Share the finished report the same way you'd share a Google Doc — anyone you add can open it in their own browser, no setup on their end at all.

One connector, many different charts

Everything the form has to offer — Status, Day, Week, Month, Year, and a field for every multiple-choice/rating/scale-type question — is available on this oneconnected source. Build a “responses by month” line chart, a “responses by district” bar chart, and a “total responses” number tile all in the same report, each just picking a different field — no need to set up a separate connection for each one.

Tracking something over time across several forms (a Form App)

If a Form App collects the same kind of record over several visits or check-ins — each one its own form/step — fill in Other form IDs to combine with iton the setup screen with those steps' form IDs. The connected source then shows one “Count” field per step (“Count — Month 1 check-in,” “Count — Month 3 check-in”), which you can chart together against Month or Year to see every step's volume over time, side by side, on one chart.

For this kind of app, also switch Count by to When it happenedinstead of the default. A coordinator who files March's check-in a few days late — still inside the app's allowed catch-up window — should have it counted under March, not under whichever day they happened to type it in. That's exactly what this setting is for.

Getting the numbers up to date later

Looker Studio remembers what it last pulled for a little while (up to about half a day) before checking again, so it won't always show something submitted a minute ago. Anyone viewing the report can click the refresh icon on a chart to pull the latest numbers right away — free, unlimited, no extra setup needed.

If something doesn't look right

What you seeWhat it means
The authorize screen rejects the keyThe key must start with fbk_ — re-copy it from Integrations rather than typing it by hand.
An error about a rejected key when the chart loadsCheck the key still has both submissions:aggregate and forms:read ticked, and hasn’t been revoked, under Integrations.
A field for a free-text or file question is missingOnly fixed-choice questions (single choice, dropdown, star rating, NPS, slider) become their own field — free-text and file questions never do, on purpose, so this stays safe to share widely.
A chart shows an error about using two dimensions at oncePick just one field to split that chart by (Status, Month, a question — not two together). Use Looker Studio’s own “Blend data” feature if you genuinely need two.
Set up before this page’s “different dimensions” update and only see one or two fieldsOpen the data source → Resource → Manage added data sources → this connector → Refresh fields, so Looker Studio picks up the full new field list.

What this doesn't do yet

  • Shows grouped totals only, on purpose — for the full, response-by-response detail, use the Power BI connector or export from Responses instead.
  • Combining several forms only gives Status/Day/Week/Month/Year and a Count per form — not a breakdown by a specific question across forms yet (two forms rarely ask the same question with the same wording anyway).
  • Anyone who wants to view the finished report needs a Google account — for someone who doesn't have one, share a downloaded image or PDF of the report instead (Looker Studio can create one for you with one click).

Frequently asked questions

Does Looker Studio show individual responses?
No. Only safe summary numbers are shared, never a raw response.
Do viewers need to install anything to see the charts?
No. The charts are free and shareable, and open in a browser with nothing to install.