# Google Forms

> Send form responses from Google Forms to Dimension Labs with the universal API.

This brings in your Google Forms responses: each text question and the respondent's answer, one conversation per response, with scale, rating and quiz scores alongside. You run it on your side: a small script pulls from the Google Forms API and posts each message to the [universal API](/api/tracker).

## What you need

- A Google Cloud project with the **Google Forms API** enabled.
- An OAuth 2.0 client ID and secret, and a Google account that can open the forms, to authorize once with the `https://www.googleapis.com/auth/forms.body.readonly` and `https://www.googleapis.com/auth/forms.responses.readonly` scopes and get a refresh token.
- The ID of each form.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Google Forms

Google's steps are in the [Forms API quickstart](https://developers.google.com/workspace/forms/api/quickstart/python) and [Configure the OAuth consent screen](https://developers.google.com/workspace/guides/configure-oauth-consent).

1. In the Google Cloud console, create or pick a project and enable the **Google Forms API**.
2. Set up the consent screen under **Menu > Google Auth platform > Branding**. Choose **Internal** under **Audience** if the forms belong to your Google Workspace organization. If you choose **External** and leave the app in **Testing**, Google expires refresh tokens after 7 days, so publish it to **In production**.
3. Go to **Menu > Google Auth platform > Clients** and click **Create Client**. Choose **Web application**, add `https://developers.google.com/oauthplayground` as an authorized redirect URI, and copy the client ID and client secret.
4. Get a refresh token once: open the [OAuth 2.0 Playground](https://developers.google.com/oauthplayground), click the settings icon, select **Use your own OAuth credentials** and enter your client ID and secret. Authorize the two scopes above with an account that can open the forms, click **Exchange authorization code for tokens**, and copy the refresh token. Keep it in a secrets manager.
5. The code below uses `google-auth-library` to turn the refresh token into a fresh access token whenever it needs one, and sends it as `Authorization: Bearer ACCESS_TOKEN`.
6. The form ID is in the form's URL: in `https://docs.google.com/forms/d/FORM_ID/edit` it is `FORM_ID`.

## 2. Pull new responses

```bash
curl -G -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode "filter=timestamp >= 2026-10-01T00:00:00Z" \
  "https://forms.googleapis.com/v1/forms/FORM_ID/responses"
```

- `GET /v1/forms/{formId}/responses` lists the responses to one form. There is no call that lists every form, so list the form IDs yourself.
- **Incremental:** `filter` supports only `timestamp > N` and `timestamp >= N`, which return responses submitted after (or at) that time. `N` is RFC 3339 UTC, for example `2014-10-02T15:01:23Z`.
- **Pagination:** `pageSize` defaults to a maximum of 5,000. Pass `nextPageToken` as `pageToken` for the next page, with the same `filter` as the first request.
- Answers are keyed by question ID. The question titles come from `GET /v1/forms/{formId}`: each entry in `items` has a `title`, and a `questionItem.question` with its `questionId`. A `textQuestion` is a short answer or, with `paragraph: true`, a paragraph question.
- **Rate limits (Google's):** listing responses counts as an expensive read: 450 per minute per project and 180 per minute per user per project. Over the limit, Google returns `429`.

## 3. Map the fields

Each text question becomes an outgoing message (the question title) followed by an incoming message (the answer), under the response ID. Skip blank answers.

| Dimension Labs | Google Forms | Notes |
|---|---|---|
| `text` | item `title` (outgoing), `answers[questionId].textAnswers.answers[].value` (incoming) | Only for `textQuestion` items. |
| `userId` | `responseId` | Google returns no respondent ID, so use the response ID. Never `respondentEmail`. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `responseId` | Prefix it, for example `google-forms-ACYDBNj...`. |
| `conversationId` | `responseId` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `lastSubmittedTime` | RFC 3339. Convert with `toMillis`. Add 1,000 ms per message. |
| `platformJson` | form ID, `totalScore` (quizzes), the answers to scale and rating questions | Scale and rating answers come back as text; the code turns them into numbers. |

**Personal data.** `respondentEmail` is filled in when the form collects email addresses: the code doesn't send it, so keep it that way. Text answers can hold names, emails and phone numbers too: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

Install the auth library in your project first: `npm install google-auth-library`.

```js
// google-forms.mjs. Environment: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN,
// GOOGLE_FORM_IDS (comma-separated), DL_API_KEY.



const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://forms.googleapis.com/v1/forms';
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET);
oauth.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN });

// Uses a fresh access token on every call (the library refreshes it when it expires),
// and retries Google's rate limits (429) and server errors (5xx) with a growing pause.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const { token } = await oauth.getAccessToken();
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Google returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// The form's text questions, in form order, and the IDs of its scale and rating questions.
async function readForm(formId) {
  const textQuestions = [], scoreIds = new Set();
  for (const item of (await get(`${API}/${formId}`)).items ?? []) {
    const q = item.questionItem?.question;
    if (!q) continue;
    if (q.textQuestion) textQuestions.push({ id: q.questionId, title: item.title });
    if (q.scaleQuestion || q.ratingQuestion) scoreIds.add(q.questionId);
  }
  return { textQuestions, scoreIds };
}

async function syncForm(formId, since, historical) {
  const { textQuestions, scoreIds } = await readForm(formId);
  const filter = `timestamp >= ${new Date(since).toISOString().replace(/\.\d{3}Z$/, 'Z')}`;
  let newest = since, pageToken = '';
  do {
    const q = new URLSearchParams({ filter, ...(pageToken && { pageToken }) });
    const page = await get(`${API}/${formId}/responses?${q}`);
    for (const r of page.responses ?? []) {
      const submitted = toMillis(r.lastSubmittedTime ?? r.createTime);
      newest = Math.max(newest, submitted);
      const text = (qid) => (r.answers?.[qid]?.textAnswers?.answers ?? []).map((a) => a.value).join(', ').trim();
      const scores = Object.fromEntries([...scoreIds].filter((qid) => text(qid)).map((qid) => [qid, Number(text(qid))]));
      const id = `google-forms-${r.responseId}`.replace(/:/g, '-');
      const base = { userId: id, sessionId: id, conversationId: id, platformJson: { formId, totalScore: r.totalScore, scores } };
      const old = historical || submitted < Date.now() - HOUR;
      let t = submitted;
      for (const question of textQuestions) {
        const answer = text(question.id);
        if (!answer) continue;
        await sendToDimensionLabs({ ...base, text: question.title, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text: answer, incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
    }
    pageToken = page.nextPageToken ?? '';
  } while (pageToken);
  return newest;
}

// Sends every response submitted at or after `since` (Unix ms), for each form. Returns the new `since`.
export async function syncGoogleForms({ since, historical = false }) {
  let newest = since;
  for (const formId of process.env.GOOGLE_FORM_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
    newest = Math.max(newest, await syncForm(formId, since, historical));
  }
  return newest;
}
```

Questions inside a grid (`questionGroupItem`) are choice rows, not free text, so the code leaves them out. One Dimension Labs source can hold all your forms: the form ID goes in `platformJson.formId`.

## 5. Run it

Save the code as `google-forms.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncGoogleForms as sync } from './google-forms.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every response since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new responses to `/track`, and they are enriched automatically every day. A response that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically. Each run reads each form once and lists its responses, so it stays far below Google's limits.

If you have responses in a file instead, [upload it](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Google Forms reference

- [Google Forms API overview](https://developers.google.com/workspace/forms/api/guides) (form IDs)
- [Forms API quickstart](https://developers.google.com/workspace/forms/api/quickstart/python) (enable the API, consent screen, OAuth client)
- [Configure the OAuth consent screen](https://developers.google.com/workspace/guides/configure-oauth-consent)
- [Retrieve forms and responses](https://developers.google.com/workspace/forms/api/guides/retrieve-forms-responses)
- [Method: forms.responses.list](https://developers.google.com/workspace/forms/api/reference/rest/v1/forms.responses/list)
- [REST Resource: forms.responses](https://developers.google.com/workspace/forms/api/reference/rest/v1/forms.responses)
- [Method: forms.get](https://developers.google.com/workspace/forms/api/reference/rest/v1/forms/get)
- [REST Resource: forms](https://developers.google.com/workspace/forms/api/reference/rest/v1/forms)
- [Usage limits](https://developers.google.com/workspace/forms/api/limits)
- [Using OAuth 2.0 to access Google APIs (refresh token expiration)](https://developers.google.com/identity/protocols/oauth2)

Checked against Google's documentation on 2026-10-26.
