Connect Looker Studio
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 BI | Looker Studio | |
|---|---|---|
| Cost | Free to build, free to view | Free to build and free for every viewer |
| Works on | Windows computers only | Any device with a web browser — phone, Chromebook, Mac, Windows |
| Who can open it | Whoever you send the file to, or invite to a paid workspace | Anyone with a Google account you share it with |
| What it can show | Every response, question by question, row by row | Grouped totals and averages only — no individual rows |
| Best for | Your own team, digging into the full detail on a Windows PC | A 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.
- 1Go to Integrations and click Create API key.
- 2Name it something recognizable, like “Looker Studio.”
- 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.
- 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:
| What | Where to find it |
|---|---|
| Your deployment address | The web address you normally use to open this app, with /v1 added to the end. |
| Your organization ID | Settings → Organization — copy button next to Organization ID. |
| This form’s ID | Open 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.
- 1Signed in to a Vibha Google Workspace account, go to script.google.com and click New project.
- 2Delete everything in the code box that opens, and paste in the first script below.
- 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.
- 4Save the project (the disk icon, or Ctrl/Cmd + S).
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;
}{
"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
- 1In the Apps Script editor, click Deploy → Test deployments.
- 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.
- 3Click Install, then copy the Head Deployment ID (or Deployment ID — the same value) shown in the box that appears.
- 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
- 1Open the address you built in Step 4 in a new browser tab, signed in with the same account.
- 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.
- 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.
- 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.
- 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 see | What it means |
|---|---|
| The authorize screen rejects the key | The 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 loads | Check 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 missing | Only 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 once | Pick 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 fields | Open 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.