Forms API
What it does: creates, changes, publishes and deletes forms, posts responses through the same pipeline a respondent uses, reads responses, unfinished responses and analytics, and manages webhooks, from your own code. What it does not do: touch polls, or change a live form without a publish.
The API is free on every plan, with no request quota beyond a rate limit of 100 requests a minute per key. The whole API is described in an OpenAPI 3.1 file you can load into Postman, Insomnia or a client generator.
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 keys can list and read forms, responses, analytics and webhooks. Read and write keys can also create, change, publish and delete forms, post and 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 create, change, publish, post or delete something. |
403 | moderation_hold | The form was paused for review when it was published, or already was. It cannot go live until it is released. |
400 | invalid_request | On a form write, `error.issues` lists each problem as a path and one sentence; on a posted response, `error.errors` names each question. |
409 | conflict | The form changed while the request was being saved. Read it again and retry. |
409 | not_published | The form is not published yet, so it takes no responses. |
409 | already_responded | A response with the same value in the duplicate-checked field already exists. |
409 | option_full | An option ran out of places. `error.errors` names the question. |
422 | logic_issues | Publishing would leave the form broken. `error.issues` lists each problem; nothing was published. |
422 | idempotency_conflict | The Idempotency-Key was already used with different content. |
423 | limit_reached | The form’s response cap is full (or `form_closed`: closed, or outside its schedule). |
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": "…"
}
} POST /forms/
Creates a form. Body: title, description, questions, theme,
settings, checked by the same schema the editor saves through. Ids are optional, options can be
plain strings, and a rating without a scale has 5 points. The form is born a draft: it has a link
but takes no responses until published. Needs a read and write key.
curl -X POST https://www.pulseformcreator.com/api/v1/forms/ \
-H "Authorization: Bearer pfc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Customer feedback",
"questions": [
{ "type": "email", "title": "Your email", "key": "email", "required": true },
{ "type": "rating", "title": "How was your visit?", "key": "rating" },
{ "type": "single_choice", "title": "Would you come back?", "key": "return", "options": ["Yes", "No"] }
]
}'
# 201, status "draft". Then: POST https://www.pulseformcreator.com/api/v1/forms/FORM_ID/publish/ PATCH /forms/{id}/
Changes a form: any of title, description, questions (replaces the list),
theme (replaces) and settings (merged key by key; null removes a key). On a
published form the change is saved as a pending draft: GET keeps returning the published form
(?draft=1 shows the draft) and respondents see nothing new until you publish.
POST /forms/{id}/publish/ and /discard/
Publish makes a draft form, or a published form's pending edits, live. It runs the editor's checks: a form that
would be broken (a jump to a deleted question, a calculation that does not parse) is a 422 with
error.issues, and nothing goes live. The content is screened before the answer; a form the screen
pauses answers 403 moderation_hold. Discard throws the pending edits away.
DELETE /forms/{id}/
Deletes the form, its responses and uploaded files. This cannot be undone. Needs a role that can delete forms.
GET /forms/{id}/analytics/
Views, starts, completions, responses, unfinished responses, completion and start rate, median completion time
and a per-day series. since (default 30 days ago) and until take an ISO 8601 time or a
date. Free plans read the last 7 days.
Field keys
A question can have a key: lowercase letters, digits and underscores, starting with a letter, at most
40, unique in the form (set it in the editor under a question's More, or in the API). It is used where a random
id would be awkward: values in a response and in a webhook, the keys of a posted
answers object, ?email= in a prefill link, and &headers=keys on a CSV or
Excel export.
The id never changes and is always sent: fields[].key is the id and fields[].fieldKey the
key. Renaming a key changes fieldKey and a property name in values from the next
delivery, and never an id, so an integration that reads ids is not broken by a rename. Read ids where you need
stability and keys where you need a name.
Responses
POST /forms/{id}/responses/
Posts a response through the form's full pipeline: the logic is replayed on the server, every answer is validated,
and the response cap, duplicate rule, option limits, schedule and quiz grading apply, then webhooks, Sheets, email
and workflows run. The form must be published. Because the caller is your server, captcha, password, honeypot,
minimum-time and once-per-browser rules do not apply, no IP address or user agent is stored, and
metadata.source is api. answers is keyed by question id or key; the values
are the stored shapes a GET returns (option ids, arrays, objects). hidden,
language, startedAt and timeZone are accepted too. Needs a read and write
key and a role that can manage responses.
Send an Idempotency-Key (1 to 255 visible characters) to make a retry safe: the same key with the same
content returns the stored response with 200 and Idempotent-Replayed: true; the same key
with other content is a 422. The key is scoped to your API key and the form. A form with a timer
records its limit but cannot vouch for your clock.
curl -X POST https://www.pulseformcreator.com/api/v1/forms/FORM_ID/responses/ \
-H "Authorization: Bearer pfc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{ "answers": { "email": "ann@example.com", "rating": 5, "return": "OPTION_ID" } }' 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.
The newest 50 responses to a form, with curl, JavaScript (fetch) and Python:
curl "https://www.pulseformcreator.com/api/v1/forms/FORM_ID/responses/?limit=50" \
-H "Authorization: Bearer pfc_live_YOUR_KEY" // JavaScript (Node 18+, Deno or Bun): the newest 50 responses.
const res = await fetch('https://www.pulseformcreator.com/api/v1/forms/FORM_ID/responses/?limit=50', {
headers: { Authorization: `Bearer ${process.env.PFC_API_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { data, pagination } = await res.json();
for (const r of data) console.log(r.submittedAt, r.fields.map((f) => f.displayValue)); # Python 3, standard library only: the newest 50 responses.
import json, os, urllib.request
req = urllib.request.Request(
"https://www.pulseformcreator.com/api/v1/forms/FORM_ID/responses/?limit=50",
headers={"Authorization": "Bearer " + os.environ["PFC_API_KEY"]},
)
with urllib.request.urlopen(req) as res:
body = json.load(res)
for r in body["data"]:
print(r["submittedAt"], [f["displayValue"] for f in r["fields"]]) 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. The eventType is
FORM_RESPONSE for a stored response (from the runner or from POST /responses/),
FORM_RESPONSE_UPDATED when one is edited, and FORM_RESPONSE_PARTIAL for an unfinished form
(its sessionId reappears on the FORM_RESPONSE that completes it). The OpenAPI file
documents each.
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
}
} MCP
Claude Code, Cursor and other MCP clients: add the server with your key to create, change and summarize forms by asking.
{
"mcpServers": {
"pulseformcreator": {
"type": "http",
"url": "https://www.pulseformcreator.com/api/mcp/",
"headers": { "Authorization": "Bearer pfc_live_YOUR_KEY" }
}
}
} 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.