# Jotform

> Send form submissions from Jotform to Dimension Labs with the universal API.

This brings in your Jotform submissions: each text field and what the respondent wrote in it, one conversation per submission, with the scores you choose alongside. You run it on your side: a small script pulls from the Jotform API and posts each message to the [universal API](/api/tracker).

## What you need

- A Jotform account. Jotform caps API calls per day by plan (see below), so a long history load on a Starter account may need more than one day.
- A Jotform API key, 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 Jotform

Jotform's steps are in the [API documentation](https://api.jotform.com/docs/).

1. Go to **My Account** and open the **API** section ([jotform.com/myaccount/api](https://www.jotform.com/myaccount/api)).
2. Click **Create a new API Key** and copy it.
3. Send it as the header `APIKEY: YOUR_API_KEY`. Jotform also takes it as the `apiKey` query parameter; the header keeps it out of URLs and logs.
4. Use the API address for your account: `https://api.jotform.com` by default, `https://eu-api.jotform.com` for EU accounts, and `https://hipaa-api.jotform.com` for HIPAA accounts. Jotform Enterprise accounts can use their own domain.
5. The form ID is the number in the form's URL: in Jotform's example, `https://www.jotformpro.com/form/30693660548966`, it is `30693660548966`. `GET /user/forms` also lists your forms with their `id`.

## 2. Pull new submissions

```bash
curl -G -H "APIKEY: $JOTFORM_API_KEY" \
  --data-urlencode 'filter={"created_at:gt":"2026-10-01 00:00:00"}' \
  --data-urlencode "orderby=created_at" --data-urlencode "limit=1000" --data-urlencode "offset=0" \
  "https://api.jotform.com/form/FORM_ID/submissions"
```

- `GET /form/{id}/submissions` lists a form's submissions. `GET /user/submissions` lists them for every form on the account.
- **Incremental:** `filter` is a JSON object such as `{"created_at:gt":"2013-01-01 00:00:00"}`. It supports `gt`, `lt` and `ne` on `id`, `created_at`, `updated_at`, `status` and a few other fields. `orderby` takes `created_at`.
- **Pagination:** `offset` (default 0) and `limit` (default 20, maximum 1,000). The response's `resultSet` has the `offset`, `limit` and `count`.
- Each submission has an `id`, `created_at` and `updated_at` (`YYYY-MM-DD HH:MM:SS`), a `status` and an `answers` object keyed by question ID. Each answer has `text` (the question label), `type` (such as `control_textbox` or `control_textarea`) and `answer` (what the submitter entered).
- **Daily limits (Jotform's):** 1,000 API calls per day on Starter, 10,000 on Bronze, 50,000 on Silver and 100,000 on Gold. Jotform Enterprise has no daily limit. Every response includes `limit-left`, the calls you have left today.

## 3. Map the fields

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

| Dimension Labs | Jotform | Notes |
|---|---|---|
| `text` | answer `text` (outgoing), answer `answer` (incoming) | For `control_textbox` and `control_textarea` fields. Labels can hold HTML: strip it. |
| `userId` | submission `id` | Jotform returns no respondent ID, so use the submission ID. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | submission `id` | Prefix it, for example `jotform-237955080346633702`. |
| `conversationId` | submission `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `created_at` | Convert with `toMillis`. Add 1,000 ms per message. |
| `platformJson` | form ID, `status`, the answers to the score questions you list | Name rating, scale or number questions in `JOTFORM_SCORE_QUESTION_IDS`. |

Jotform's API docs don't give a time zone for `created_at`. The code reads it as UTC, and builds the filter the same way, so the cursor stays consistent. Before you go live, compare one submission's `created_at` with the time you see for it in Jotform: if they differ, every timestamp is off by that offset.

**Personal data.** `control_fullname`, `control_email` and phone fields aren't text fields here, so the code doesn't send them. Don't add them as score questions. The submission's `ip` isn't sent either. A text box can still hold a name or email: redact before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// jotform.mjs. Environment: JOTFORM_API_KEY, JOTFORM_FORM_IDS (comma-separated),
// JOTFORM_API (default https://api.jotform.com; https://eu-api.jotform.com or https://hipaa-api.jotform.com if yours is there),
// JOTFORM_SCORE_QUESTION_IDS (optional, comma-separated question IDs), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = process.env.JOTFORM_API ?? 'https://api.jotform.com';
const list = (v) => (v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const SCORE_IDS = new Set(list(process.env.JOTFORM_SCORE_QUESTION_IDS));
const TEXT_TYPES = ['control_textbox', 'control_textarea'];
const stamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' '); // "YYYY-MM-DD HH:MM:SS"
const strip = (s) => String(s ?? '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();

// Retries rate limits (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: { APIKEY: process.env.JOTFORM_API_KEY } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Jotform returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function syncForm(formId, since, historical) {
  // gt is "after", to the second: start one second early so nothing on the boundary is missed.
  const filter = JSON.stringify({ 'created_at:gt': stamp(since - 1000) });
  let newest = since;
  for (let offset = 0; ; offset += 1000) {
    const q = new URLSearchParams({ filter, orderby: 'created_at', limit: '1000', offset: String(offset) });
    const { content = [] } = await get(`${API}/form/${formId}/submissions?${q}`);
    for (const s of content) {
      const created = toMillis(s.created_at);
      newest = Math.max(newest, created);
      const answers = Object.entries(s.answers ?? {}).sort(([a], [b]) => Number(a) - Number(b));
      const scores = Object.fromEntries(answers.filter(([qid, a]) => SCORE_IDS.has(qid) && a.answer !== undefined && a.answer !== '')
        .map(([qid, a]) => [qid, Number(a.answer)]));
      const id = `jotform-${s.id}`;
      const base = { userId: id, sessionId: id, conversationId: id, platformJson: { formId, status: s.status, scores } };
      const old = historical || created < Date.now() - HOUR;
      let t = created;
      for (const [, a] of answers) {
        if (!TEXT_TYPES.includes(a.type) || typeof a.answer !== 'string' || !a.answer.trim()) continue;
        await sendToDimensionLabs({ ...base, text: strip(a.text), incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text: a.answer.trim(), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
    }
    if (content.length < 1000) return newest;
  }
}

// Sends every submission created at or after `since` (Unix ms), for each form. Returns the new `since`.
export async function syncJotform({ since, historical = false }) {
  let newest = since;
  for (const formId of list(process.env.JOTFORM_FORM_IDS)) {
    newest = Math.max(newest, await syncForm(formId, since, historical));
  }
  return newest;
}
```

One Dimension Labs source can hold all your forms: the form ID goes in `platformJson.formId`.

## 5. Run it

Save the code as `jotform.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 { syncJotform as sync } from './jotform.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every submission since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new submissions to `/track`, and they are enriched automatically every day. A submission 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 costs one call per form, plus one per 1,000 submissions. Every 15 minutes that is 96 runs a day per form, which fits inside even the Starter cap of 1,000 calls for a handful of forms. If you have submissions 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.

## Jotform reference

- [API documentation](https://api.jotform.com/docs/) (API endpoints, authentication, daily limits, form submissions, form questions)
- [API key page](https://www.jotform.com/myaccount/api)

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