# Typeform

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

This brings in your Typeform responses: each text question and the respondent's answer, with ratings, scores and hidden fields alongside. You run it on your side with the [universal API](/api/tracker), using Typeform's Responses API.

## What you need

- A Typeform account. Typeform says API access works on standard accounts, with no developer account needed.
- A Typeform personal access token with the `responses:read` and `forms:read` scopes, and 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 Typeform

Typeform's steps are in [Personal access token](https://www.typeform.com/developers/get-started/personal-access-token/).

1. Log in to Typeform. In the drop-down menu next to your username in the upper-left corner, click **Account**.
2. In the left menu, click **Personal tokens**, then **Generate a new token**.
3. Name the token, choose the scopes `responses:read` and `forms:read`, and click **Generate token**.
4. Send it as `Authorization: Bearer YOUR_TOKEN`. The form ID is in the form's URL: in `https://mysite.typeform.com/to/u6nXL7` it is `u6nXL7`.

If your responses are stored in the EU Data Center, use `https://api.eu.typeform.com` or `https://api.typeform.eu` instead of `https://api.typeform.com`.

## 2. Pull new responses

```bash
curl -H "Authorization: Bearer $TYPEFORM_TOKEN" \
  "https://api.typeform.com/forms/FORM_ID/responses?page_size=100&since=2026-10-01T00:00:00&sort=submitted_at,asc"
```

- `GET /forms/{form_id}/responses` returns completed responses by default.
- **Incremental:** `since` and `until` take a UTC time to the second (`2020-03-20T14:00:59`) or Unix seconds, and are inclusive. `excluded_response_ids` skips IDs you already sent. The default sort is `submitted_at,desc`.
- Typeform notes that very recent responses (within about the last 30 minutes) may not be returned yet. Set `until` to 30 minutes ago so a late response isn't skipped.
- **Pagination:** for more than 1,000 responses, Typeform says to narrow each request with `since`/`until` or `before`/`after`. The example below moves `since` forward page by page.
- Question titles come from `GET /forms/{form_id}`: each item in `fields` has an `id`, a `title` and a `type`.
- **Rate limit (Typeform's):** two requests per second per Typeform account, for the Create and Responses APIs.

## 3. Map the fields

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

| Dimension Labs | Typeform | Notes |
|---|---|---|
| `text` | field `title` (outgoing), `answers[].text` where `field.type` is `short_text` or `long_text` (incoming) | |
| `userId` | a hidden field such as your internal customer ID, if you pass one | Otherwise use `response_id`. Never an email address. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `response_id` | |
| `conversationId` | `response_id` | |
| `dimensionlabs_timestamp` | `submitted_at` | Convert with `toMillis`. Add 1,000 ms per message. |
| `platformJson` | `answers[].number` for `rating`, `opinion_scale` and number fields, `calculated.score`, `hidden`, `metadata.platform` | Hidden fields are the place for account or order IDs to join on. |

**Personal data.** Typeform's `email` and `phone_number` answers aren't sent by this code; keep it that way. Don't pass emails or names in hidden fields, because the code sends `hidden` in `platformJson`. Redact open-text answers too. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// typeform.mjs. Environment: TYPEFORM_TOKEN, TYPEFORM_FORM_IDS (comma-separated),
// TYPEFORM_API (default https://api.typeform.com; use your EU address if your data is there), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = process.env.TYPEFORM_API ?? 'https://api.typeform.com';
const iso = (ms) => new Date(ms).toISOString().slice(0, 19);

// Retries Typeform's rate limit (429) and server errors (5xx), honoring Retry-After when it is sent.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.TYPEFORM_TOKEN}` } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Typeform returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function syncForm(formId, since, historical) {
  const titles = {};
  const walk = (fields = []) => fields.forEach((f) => { titles[f.id] = f.title; walk(f.properties?.fields); });
  walk((await get(`${API}/forms/${formId}`)).fields);
  const until = iso(Date.now() - 30 * 60 * 1000); // Typeform may not return the last 30 minutes yet
  let from = since, seen = [], newest = since;
  for (;;) {
    const q = new URLSearchParams({ page_size: '100', since: iso(from), until, sort: 'submitted_at,asc' });
    if (seen.length) q.set('excluded_response_ids', seen.join(','));
    const { items } = await get(`${API}/forms/${formId}/responses?${q}`);
    for (const r of items) {
      const scores = Object.fromEntries((r.answers ?? []).filter((a) => a.type === 'number').map((a) => [a.field.ref ?? a.field.id, a.number]));
      const base = { userId: r.hidden?.customer_id ?? r.response_id, sessionId: r.response_id, conversationId: r.response_id,
        platformJson: { formId, scores, score: r.calculated?.score, hidden: r.hidden, platform: r.metadata?.platform } };
      const submitted = toMillis(r.submitted_at);
      const old = historical || submitted < Date.now() - HOUR;
      let t = submitted;
      for (const a of r.answers ?? []) {
        if (!['short_text', 'long_text'].includes(a.field.type) || !a.text?.trim()) continue;
        await sendToDimensionLabs({ ...base, text: titles[a.field.id] ?? a.field.ref, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text: a.text.trim(), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
      const second = Math.floor(submitted / 1000) * 1000;
      seen = second === from ? [...seen, r.response_id] : [r.response_id]; // skip these on the next page
      from = second;
      newest = Math.max(newest, submitted);
    }
    if (items.length < 100) return newest;
  }
}

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

`customer_id` stands for a hidden field you define yourself. Remove it if you don't pass one. One Dimension Labs source can hold all your forms: the form ID goes in `platformJson.formId`.

## 5. Run it

Save the code as `typeform.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 { syncTypeform as sync } from './typeform.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. Because the code waits 30 minutes for Typeform to settle, a response is 30 to 45 minutes old when it is sent: it still goes to `/track`, and is 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.

If you download responses from Typeform as 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.

## Typeform reference

- [Get started](https://www.typeform.com/developers/get-started/) (base URL, EU Data Center, rate limits)
- [Personal access token](https://www.typeform.com/developers/get-started/personal-access-token/)
- [OAuth 2.0 scopes](https://www.typeform.com/developers/get-started/scopes/)
- [Responses API](https://www.typeform.com/developers/responses/)
- [Retrieve responses](https://www.typeform.com/developers/responses/reference/retrieve-responses/)
- [Retrieve form](https://www.typeform.com/developers/create/reference/retrieve-form/)
- [Create form](https://www.typeform.com/developers/create/reference/create-form/) (field properties)

Checked against Typeform's documentation on 2026-10-13.
