JavaScript examples

Node snippets for the Customer API, with every step spelled out — a small fetch helper, cursor walking, idempotent submits, and backoff.

Updated 2026-08-07

These snippets run on Node 18 or newer (they use the built-in fetch, nothing to install). Each one builds on a small helper defined first.

Setup: a small helper

The helper does three things every call needs: attach your key, parse the JSON, and turn an error response into a thrown Error that carries the API's error code and request_id (quote that id when contacting support).

// Your API key, minted at /api-keys in the dashboard.
const FS_KEY = process.env.FS_KEY
 
// The API base URL.
const BASE = process.env.FS_BASE ?? 'https://fieldscroll.app/api/v1'
 
async function api(path, options = {}) {
  const response = await fetch(`${BASE}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${FS_KEY}`,
      'Content-Type': 'application/json',
      ...(options.headers ?? {}),
    },
  })
 
  // Every response body is JSON (some error pages may not be —
  // the catch keeps a broken body from masking the real status).
  const body = await response.json().catch(() => null)
 
  if (!response.ok) {
    const error = new Error(body?.error?.message ?? `HTTP ${response.status}`)
    error.code = body?.error?.code        // e.g. 'rate_limited'
    error.status = response.status        // e.g. 429
    error.requestId = body?.request_id    // quote this in support emails
    error.retryAfter = response.headers.get('Retry-After')
    throw error
  }
  return body
}

The ids used below (ORG, FORM, USER_ID, and so on) are UUIDs you can read out of the dashboard URL on the matching page, or from the id field of an earlier API response.

List organizations

const orgs = await api('/organizations')
 
// orgs.data is the list; each row's `id` is the ORG value below.
console.log(orgs.data)

Walk all records, page by page

Each page's meta.next_cursor is a bookmark marking the last row you received; passing it back resumes after that exact row. When it comes back null, you have everything. Pagination explains why this beats page numbers.

const allRecords = []
let cursor = null
 
while (true) {
  // Build the query: 100 rows per page, plus the bookmark when we have one.
  const query = new URLSearchParams({ limit: '100' })
  if (cursor) query.set('cursor', cursor)
 
  // Fetch one page.
  const page = await api(`/organizations/${ORG}/forms/${FORM}/records?${query}`)
 
  // Keep the rows.
  allRecords.push(...page.data)
 
  // Read the bookmark; null means that was the last page.
  cursor = page.meta.next_cursor
  if (!cursor) break
}
 
console.log(`fetched ${allRecords.length} records`)

Submit a record, safe to retry

The Idempotency-Key header makes the submit safe to retry: if the same request runs twice (a network blip, a queue re-drive), the second call returns the first result instead of creating a duplicate record.

import { randomUUID } from 'node:crypto'
 
const created = await api(`/organizations/${ORG}/forms/${FORM}/records`, {
  method: 'POST',
  headers: { 'Idempotency-Key': randomUUID() },
  body: JSON.stringify({
    // The user the submission is recorded under.
    created_by: USER_ID,
    // Keys are the form's field ids. The payload is checked against
    // the published version: unknown ids, wrong value types, or list
    // values outside the current options return 400 with details.
    field_values: { site_id: 'S-12', condition: 'fair' },
  }),
})
 
console.log('new record id:', created.data.id)

Update a record

An update is a merge: only the field ids you send change, and sending null clears that field. By default the update is validated against the record's own form version, so publishing a new version of the form does not break this call.

const updated = await api(
  `/organizations/${ORG}/forms/${FORM}/records/${RECORD_ID}`,
  {
    method: 'PATCH',
    body: JSON.stringify({
      field_values: { condition: 'good' },
    }),
  },
)
 
console.log('still version:', updated.data.form_version)

To move the record to the newest form version instead, add schema: 'current' to the body — the whole merged record is checked against the current version, and the record adopts it when it conforms. A checked-out (dispatched) record returns 409 until the dispatch ends.

Back off when rate-limited

Every key has a per-minute request budget. A 429 response means "slow down" and carries a Retry-After header saying how many seconds to wait. This wrapper waits and retries up to three times.

async function callWithBackoff(path, options) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await api(path, options)
    } catch (error) {
      // Anything other than rate limiting is a real error — rethrow.
      if (error.code !== 'rate_limited') throw error
 
      // Wait the number of seconds the API asked for (at least 1).
      const seconds = Number(error.retryAfter ?? 1)
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
    }
  }
  throw new Error('still rate-limited after 3 attempts')
}

Rate limiting covers the budget and headers in detail.

Render and deliver a report

The posted body is exactly what the template's merge fields see — a template field {$name} reads the name key. The render is queued (202), the document is rendered once, and it is then sent to every destination configured on the report.

const run = await api(`/organizations/${ORG}/reports/${TEMPLATE}/merge`, {
  method: 'POST',
  body: JSON.stringify({ name: 'Site 12', score: 84 }),
})
console.log('queued run:', run.merge_run_id)
 
// Track it under the template's deliveries — one row per destination.
const deliveries = await api(`/organizations/${ORG}/reports/${TEMPLATE}/deliveries`)
console.log(deliveries.data)

Subscribe to webhooks

const subscription = await api(`/organizations/${ORG}/webhooks`, {
  method: 'POST',
  body: JSON.stringify({
    name: 'production',
    url: 'https://hooks.example.com/fieldscroll',
    events: ['record.created', 'form.published'],
  }),
})
 
console.log('subscription id:', subscription.data.id)

Note: records created through this API do not fire webhooks — see webhooks and the API.

Retry a failed delivery

// Both ids come from the delivery history:
// GET /organizations/{ORG}/webhooks/{WEBHOOK}/deliveries
await api(
  `/organizations/${ORG}/webhooks/${WEBHOOK}/deliveries/${DELIVERY}/retry`,
  { method: 'POST' },
)