REST API
Read your forms and responses, delete responses and manage webhooks from your own code. The API is free on every plan, with no request quota beyond a rate limit of 100 requests a minute per key.
Base URL: https://www.pulseformcreator.com/api/v1/. Requests and responses are JSON. Every path ends in a slash; a path without
one gets a 308 redirect to the slashed form, which most HTTP clients follow.
Quick start
- Sign in and open Account, then API keys.
- Create a key. Copy it straight away: it is shown once.
- Check it works:
curl https://www.pulseformcreator.com/api/v1/me/ \
-H "Authorization: Bearer pfc_live_YOUR_KEY" {
"data": {
"user": { "id": "8b1c…", "email": "you@example.com" },
"key": { "id": "0f3e…", "scopes": ["read"] }
}
} Authentication
Send the key in the Authorization header as a bearer token:
Authorization: Bearer pfc_live_…. Keys start with pfc_live_, so secret scanners can
spot one that leaks into a repository.
- Read only keys can list and read forms, responses and webhooks. Read and write keys can also delete responses and add or remove webhooks.
-
A key acts as the person who created it: their own forms, plus forms in any team workspace they belong to,
with the same role they have there. Viewers can read forms and responses; builders can read forms but not
responses; editors and admins can also delete responses and manage webhooks. Each form in
GET /forms/says whichroleapplies. Polls are not part of v1. - We store only a SHA-256 hash of each key. If you lose one, revoke it and create another. An account can hold 20 active keys.
- Revoking a key takes effect on its next request.
- Call the API from a server, not a browser. The API sends no CORS headers, so a key cannot be used from a web page on another site, which also keeps it out of your page source.
Rate limits
Each key can make 100 requests per 60 seconds, counted over a sliding window.
Every response carries X-RateLimit-Limit: 100. Past the limit you get a 429 with
Retry-After: 60; wait that many seconds and carry on. Requests with a
missing or wrong key are limited separately, per IP address.
Errors
Every error has the same shape, and the HTTP status always matches error.status:
{
"error": {
"status": 404,
"code": "not_found",
"message": "Form not found."
}
} | Status | Code | Meaning |
|---|---|---|
400 | invalid_request | A parameter or the body is wrong. The message says which. |
401 | unauthorized | No key, a malformed key, or a revoked key. |
403 | insufficient_scope | A read-only key tried to delete or change something. |
403 | forbidden | Your role in that workspace does not allow this (for example, a viewer deleting a response). |
404 | not_found | The form, response, webhook or endpoint does not exist, or the key cannot see it. |
405 | method_not_allowed | The endpoint exists but not with that method. See the Allow header. |
429 | rate_limited | More than 100 requests in 60 seconds on this key. Wait for Retry-After. |
500 | internal_error | Our fault. Retry with backoff; contact us if it persists. |
A form or response that belongs to someone else answers 404, not 403, so the API never
confirms that an id exists.
Pagination
List endpoints return up to limit items (default 50, maximum 100) and a
pagination block. When hasMore is true, pass nextCursor back as
cursor to get the next page. Cursors are opaque; keep the other parameters the same between pages.
Cursor pages stay correct while new responses arrive: nothing is skipped or repeated.
{
"data": [ … ],
"pagination": {
"limit": 50,
"hasMore": true,
"nextCursor": "eyJ2IjoxLCJ0Ijoi…"
}
} // Node 18+: every response to one form, oldest first.
const KEY = process.env.PFC_API_KEY;
const base = 'https://www.pulseformcreator.com/api/v1/forms/FORM_ID/responses/';
let cursor = null;
const all = [];
do {
const url = new URL(base);
url.searchParams.set('order', 'asc');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 60) * 1000));
continue;
}
if (!res.ok) throw new Error((await res.json()).error.message);
const body = await res.json();
all.push(...body.data);
cursor = body.pagination.nextCursor;
} while (cursor); Forms
GET /me/
The account behind the key, and the key's id and scopes. Use it to check a key.
GET /forms/
Your forms, newest first.
status:draft,publishedorclosed(optional)limit,cursor: see pagination
{
"data": [
{
"id": "5d0c2c1e-…",
"title": "Customer feedback",
"status": "published",
"url": "https://www.pulseformcreator.com/s/k3v9qz2m/",
"anonymous": false,
"questionCount": 6,
"role": "owner",
"createdAt": "2026-09-01T02:14:07.511+00:00",
"updatedAt": "2026-09-20T09:40:12.004+00:00",
"publishedAt": "2026-09-01T02:20:00+00:00"
}
],
"pagination": { "limit": 50, "hasMore": false, "nextCursor": null }
} GET /forms/{id}/
One form: a summary of each question (id, type, label, whether it is required, and choice options), its hidden
fields and calculated variables, the response count, and blocks, the full definition including
layout blocks, logic and endings. Question ids are the keys you will see in responses.
{
"data": {
"id": "5d0c2c1e-…",
"title": "Customer feedback",
"description": null,
"status": "published",
"url": "https://www.pulseformcreator.com/s/k3v9qz2m/",
"anonymous": false,
"questionCount": 2,
"responseCount": 418,
"questions": [
{ "id": "q_rating", "type": "rating", "label": "How was your visit?", "required": true },
{
"id": "q_source", "type": "single_choice", "label": "How did you hear about us?", "required": false,
"options": [ { "id": "o_search", "label": "Search" }, { "id": "o_friend", "label": "A friend" } ]
}
],
"hiddenFields": [ { "name": "utm_source", "label": "utm_source" } ],
"variables": [ { "id": "score", "name": "Score", "type": "number" } ],
"blocks": [ … the full definition, including layout blocks and endings … ],
"createdAt": "…", "updatedAt": "…", "publishedAt": "…"
}
} Responses
GET /forms/{id}/responses/
A page of responses, newest first.
order:desc(default, newest first) orasc-
since: only responses submitted at or after this time. An ISO 8601 date-time (2026-09-01T00:00:00Z) or a date (2026-09-01, UTC). -
until: only responses submitted before this time. A plain date includes that whole day. limit,cursor: see pagination
To sync new responses, keep the submittedAt of the newest one you have and ask for
since that time with order=asc, or add a webhook and skip polling.
GET /forms/{id}/responses/{responseId}/
One response.
{
"data": {
"id": "c9a4e0d2-…",
"formId": "5d0c2c1e-…",
"submittedAt": "2026-09-21T23:05:41.228913+00:00",
"fields": [
{ "key": "q_rating", "label": "How was your visit?", "type": "rating", "value": 4, "displayValue": "4" },
{ "key": "q_source", "label": "How did you hear about us?", "type": "single_choice",
"value": "o_friend", "displayValue": "A friend" },
{ "key": "hidden:utm_source", "label": "utm_source", "type": "hidden_field",
"value": "newsletter", "displayValue": "newsletter" },
{ "key": "var:score", "label": "Score", "type": "calculated_field", "value": 8, "displayValue": "8" }
],
"answers": { "q_rating": 4, "q_source": "o_friend" },
"hidden": { "utm_source": "newsletter" },
"variables": { "score": 8 },
"endingId": null,
"metadata": {
"startedAt": "2026-09-21T23:04:02.000Z",
"durationMs": 99228,
"source": null,
"ipHash": "3f9a…",
"userAgent": "Mozilla/5.0 …",
"sessionId": "s_…"
}
}
} What a response contains:
-
fields: one entry per question the respondent reached, then one per hidden field (hidden:<name>) and one per variable (var:<id>). This is exactly thedata.fieldsarray a webhook delivers, and it follows Tally's webhook field shape, so code written for either reads it.valueis the stored value (option ids, arrays, objects);displayValueis readable text (option labels, joined lists). answers: the stored answers keyed by question id.hidden,variables,endingId: hidden field values, final calculated values, and the ending shown.-
metadata: when the respondent started, how long they took, andsource(tally_importfor imported responses). For forms that do not use anonymous mode it also hasipHash(a one-way hash, never the address),userAgentandsessionId. - Anonymous forms:
ipHash,userAgentandsessionIdare left out altogether, including for responses saved before anonymous mode was turned on. - File uploads and signatures: each stored file gets a
urlthat downloads it without signing in. The link expires after an hour; fetch the response again for a fresh one.
DELETE /forms/{id}/responses/{responseId}/
Deletes the response, its uploaded files and the visit linked to it. This cannot be undone. Needs a read and
write key. Returns { "data": { "id": "…", "deleted": true } }.
Webhooks
These are the same webhooks as a form's Workflows tab: added here, they show up there, and the other way round. Each new response is POSTed to the URL as JSON within seconds, with retries for timeouts, 429s and 5xx responses. Private and local addresses are refused.
GET /forms/{id}/webhooks/
The form's webhooks, with the result of each one's latest delivery. Secrets are never returned.
POST /forms/{id}/webhooks/
Body: url (required, https), name, signing (default
true), enabled (default true). Needs a read and write key. The response
includes signingSecret once; store it where your receiver can read it.
curl -X POST https://www.pulseformcreator.com/api/v1/forms/FORM_ID/webhooks/ \
-H "Authorization: Bearer pfc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/pulseformcreator", "name": "CRM sync" }' {
"data": {
"webhook": {
"id": "e71f…",
"formId": "5d0c2c1e-…",
"name": "CRM sync",
"enabled": true,
"url": "https://example.com/hooks/pulseformcreator",
"signing": true,
"secretHint": "whsec_…Xk2Q",
"createdAt": "…",
"updatedAt": "…",
"lastDelivery": null
},
"signingSecret": "whsec_…"
}
} GET /forms/{id}/webhooks/{webhookId}/ and DELETE
Read one webhook, or remove it and its delivery log. Deleting needs a read and write key.
Verifying webhook signatures
Every delivery from a signed webhook carries two headers:
PulseFormCreator-Timestamp: Unix time in seconds when the request was signed.PulseFormCreator-Signature:sha256=followed by the hex HMAC-SHA256 of{timestamp}.{raw body}, keyed with your signing secret.
To verify, recompute the HMAC over the raw body exactly as you received it (before parsing the JSON), compare in constant time, and reject timestamps more than five minutes old so a captured request cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string), before JSON.parse.
export function verify(secret, rawBody, headers) {
const timestamp = headers['pulseformcreator-timestamp'];
const signature = headers['pulseformcreator-signature']; // "sha256=<hex>"
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const given = signature.replace(/^sha256=/, '');
return given.length === expected.length &&
timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex'));
} import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, headers) -> bool:
timestamp = headers.get("PulseFormCreator-Timestamp")
signature = headers.get("PulseFormCreator-Signature", "")
if not timestamp or abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.removeprefix("sha256="), expected) The body is the same for every webhook:
{
"eventId": "c9a4e0d2-…",
"eventType": "FORM_RESPONSE",
"createdAt": "2026-09-21T23:05:42.017Z",
"data": {
"responseId": "c9a4e0d2-…",
"submissionId": "c9a4e0d2-…",
"surveyId": "5d0c2c1e-…",
"surveyTitle": "Customer feedback",
"surveyUrl": "https://www.pulseformcreator.com/s/k3v9qz2m/",
"createdAt": "2026-09-21T23:05:41.228Z",
"fields": [ … same shape as a response's fields … ],
"hidden": { "utm_source": "newsletter" },
"variables": { "score": 8 },
"endingId": null
}
} Versioning
This is version 1, and every response says so in X-API-Version: v1. Within v1 we only add things:
new endpoints, new optional parameters, new fields in responses. Ignore fields you do not know. Anything that
would break existing code goes into a v2, and v1 keeps working.
Questions or something missing? Contact us. The integrations guide covers webhooks, Zapier, Make and n8n without code.