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' },
)