Your responses, as JSON, on your schedule.
Use three read-only HTTPS endpoints to list workspace surveys, fetch a survey’s question definitions, and page through its completed responses.
Pro. A key on a workspace that is not on Pro answers 402.
01Create a key
pf_live_ followed by 40 hex characters; only a SHA-256 hash of it is stored, so a lost key is replaced rather than recovered. Send it as Authorization: Bearer pf_live_…. Keep keys out of query strings, browser JavaScript, mobile app bundles and embeds. Store them on your server or in a secrets manager. To rotate a key, create a replacement, update your integration, then revoke the old key.02List the surveys
GET /api/v1/surveys returns up to 200 surveys at a time in the key’s workspace, newest first, each with its id, slug, title, status, open and close times, and an exact count of completed, non-test responses. A key can only ever see its own workspace. Set the page size with ?limit= (1–200, default 200), and fetch the next page with ?cursor= set to the response’s next_cursor, which is null on the last page.
curl --fail-with-body https://plumeform.com/api/v1/surveys \
-H "Authorization: Bearer $PLUMEFORM_API_KEY"03Fetch the question definitions once
GET /api/v1/surveys/:id returns the survey plus a questions array: each question’s id, type, title, whether it is required, and its options, matrix rows and columns, or semantic-differential pairs, each with their own ids, plus a slider’s range and step, a date’s mode and the parts an address asks for. Response payloads are keyed by those ids and hold stored values rather than labels, so this is the call that lets you turn q_recommend: 9 into something a person can read. It reflects the published version of the survey, or the draft if it has never been published, so fetch it again after you publish a change. The response is a single object, without a data wrapper. Use the survey UUID from the list endpoint, not the public survey slug.04Page through the responses
GET /api/v1/surveys/:id/responses returns completed, non-test responses oldest first. limit is 1 to 1000 and defaults to 100. When there is more, next_cursor is a token to hand back as cursor; when it is null, you have everything. It is keyset pagination on (completed_at, id), not an offset, so pages do not shift or repeat while new responses arrive mid-crawl. A response appears here 30 seconds after it is completed.
{
"data": [
{
"id": "4b90…",
"completed_at": "2026-09-24T18:22:07.441+00:00",
"duration_s": 96,
"device": "mobile",
"country": "GB",
"condition": "b",
"params": { "participantId": "P0042" },
"timings": { "q_comment": 31 },
"answers": { "q_recommend": 9, "q_comment": "The checkout was quick." }
}
],
"next_cursor": "eyJ0IjoiMjAy…"
}| Field | Meaning |
|---|---|
| id | Response UUID. Use this as the unique key when importing or syncing. |
| completed_at | ISO timestamp, including the original fractional precision. Preserve this string. |
| duration_s | Rounded elapsed seconds, or null when timing is unavailable. |
| device / country | Captured device category and country code; either may be null. |
| condition | Assigned experiment condition, or null. Removed from params and returned here. |
| params | Captured URL parameters. Empty object when none were captured. |
| timings | Time per question in seconds, keyed by question ID. May be empty. |
| answers | Raw stored answers keyed by question ID. Unanswered questions may be absent. |
Text answers are strings and scales are numbers. Choices use option IDs; checkboxes and ranking use arrays of IDs; matrix answers map row IDs to column IDs. An Other answer uses __other__:your text instead of an option ID. MaxDiff uses {best, worst}; constant sum maps option IDs to points; semantic differential maps pair IDs to scale values. Signatures and sketches are data-URL strings and uploads are storage paths, not public download URLs. An address is an object of its parts with an ISO country code, and a date range is one string, 2026-01-15/2026-01-20. Keep compound answers as JSON and use questions to interpret their structure.
05Sync incrementally with since
Keep the completed_at of the last response you stored and pass it as since on the next run: only responses completed strictly after it come back. It must be a full ISO 8601 timestamp with an offset or Z. Use URLSearchParams to encode it, including any + in an offset. Combine since with the cursor loop and a nightly job costs one request on a quiet day.
// Save as export-responses.mjs and run with Node.js 20+.
// Set PLUMEFORM_API_KEY and SURVEY_ID in your environment.
// Optional SINCE: an ISO timestamp from a previous successful run.
const { PLUMEFORM_API_KEY: key, SURVEY_ID: id, SINCE: since } = process.env;
if (!key || !id) throw new Error("Set PLUMEFORM_API_KEY and SURVEY_ID");
const headers = { Authorization: "Bearer " + key };
let cursor = null;
do {
const url = new URL("https://plumeform.com/api/v1/surveys/" + id + "/responses");
url.searchParams.set("limit", "100");
if (since) url.searchParams.set("since", since);
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, { headers, signal: AbortSignal.timeout(20000) });
if (!response.ok) {
throw new Error("API " + response.status + ": " + await response.text());
}
const page = await response.json();
// This writes one response per line. In a sync job, upsert by row.id.
for (const row of page.data) console.log(JSON.stringify(row));
cursor = page.next_cursor;
} while (cursor);Responses default to oldest first. Add order=desc for newest first and keep that order through every cursor page. Treat cursors as opaque: send them back unchanged with the same survey and since value. Only advance your saved checkpoint after every page has been processed successfully. Keep timestamps as strings to preserve microseconds.
Responses are served once they are 30 seconds old. A response is timestamped when it starts saving, and a save can take a few seconds when many arrive at once, so without the delay one could become visible after a newer one you had already read, behind your checkpoint. Thirty seconds is longer than any save can take, so a since checkpoint never steps past a response. A response you submit to test your integration shows up half a minute later.
Upsert by response ID, so a run repeated after a failure never duplicates rows. Pagination is a moving view of the data, not a frozen snapshot. Deleted responses do not produce a deletion event; periodically reconcile IDs if your copy must reflect deletions.
06Check API health and uptime
GET /api/v1/status is a public health check and needs no API key. It returns 200 when the route can reach its database ping, and 503 when the database does not answer. Use HEAD for the same status code without a response body. The endpoint is rate-limited to 60 requests per minute.
# Full health details
curl --fail-with-body https://plumeform.com/api/v1/status
# Status code only
curl --head https://plumeform.com/api/v1/statusThe scheduled probe also keeps rolling measurements of database round-trip time and availability. It checks from one location and does not exercise a workspace key or every API endpoint, so use it as a signal, not as a substitute for monitoring your own integration.
API and database checks
Last check Oct 17, 2026, 8:40 PM UTC
24 hours
100%
359 checks · p95 89 ms
7 days
100%
2519 checks · p95 118 ms
30 days
Collecting
6899 checks so far
One probe location checks the API route and its database connection every four minutes. This is not a guarantee of end-to-end availability from every region or of authenticated API-key requests.
07Handle errors
Check the HTTP status before using a response. Application errors return { "error": "…" }; network and hosting errors can have a different body.
| Status | Meaning | What to do |
|---|---|---|
| 400 | Invalid since timestamp or malformed cursor | Correct the parameter. Do not edit or decode cursor tokens. |
| 401 | Missing, malformed, unknown or revoked key | Check the Bearer header, workspace, and whether the key was revoked. |
| 402 | Workspace does not currently have Pro access | Ask the owner to review the workspace plan. |
| 404 | Invalid survey UUID, missing survey, or a survey in another workspace | Use an id from the list endpoint with a key belonging to that workspace. |
| 429 | More than 240 requests per minute per key | Pause, then retry with exponential backoff and jitter. Keep concurrency low. |
| 5xx | Server or upstream failure; 500 may also indicate an unreadable survey document | Retry transient failures a limited number of times. Contact support if the same request keeps failing. |
The rate limit is shared across the three data endpoints for a key. Do not assume a Retry-After header is present. A useful retry policy waits 1, 2, 4, 8, then 16 seconds, adds a small random delay, and stops after that. For support, send the endpoint, HTTP status and request time, never your API key.
API limits
It is read-only. There is no endpoint that creates a survey, edits a questionnaire, submits a response or deletes anything, so a leaked key exposes data and cannot alter it. There is also no pull for partials, screened-out responses or test submissions. Completed responses remain in the API even when quality rules exclude them from your dashboard or exports.
Rows come back raw rather than aggregated. Saved clean-dataset rules, answer filters, weighting, quality flags, computed scores and dashboard chart calculations are not applied or returned. Use the dashboard for analysis and a filtered CSV or XLSX export when you need those export fields.
The question endpoint describes the current published questionnaire, not each response’s historical version. The response payload does not include a version ID or provide a historical questionnaire endpoint. Save question definitions when your questionnaire changes and avoid changing an existing question’s meaning during collection.
Choosing an integration
Use a webhook when something should happen the moment a response lands, Google Sheets when a person wants to watch responses arrive in a spreadsheet, and the API to fetch response history on your own schedule.
For timely updates and recovery from missed events, use a webhook alongside a scheduled API sync with since. Webhooks are delivered once; API requests can be retried.
See also: signed webhooks · Google Sheets sync · the help center