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

  1. Sign in and open Account, then API keys.
  2. Create a key. Copy it straight away: it is shown once.
  3. 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 which role applies. 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."
  }
}
StatusCodeMeaning
400invalid_requestA parameter or the body is wrong. The message says which.
401unauthorizedNo key, a malformed key, or a revoked key.
403insufficient_scopeA read-only key tried to delete or change something.
403forbiddenYour role in that workspace does not allow this (for example, a viewer deleting a response).
404not_foundThe form, response, webhook or endpoint does not exist, or the key cannot see it.
405method_not_allowedThe endpoint exists but not with that method. See the Allow header.
429rate_limitedMore than 100 requests in 60 seconds on this key. Wait for Retry-After.
500internal_errorOur 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, published or closed (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) or asc
  • 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 the data.fields array a webhook delivers, and it follows Tally's webhook field shape, so code written for either reads it. value is the stored value (option ids, arrays, objects); displayValue is 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, and source (tally_import for imported responses). For forms that do not use anonymous mode it also has ipHash (a one-way hash, never the address), userAgent and sessionId.
  • Anonymous forms: ipHash, userAgent and sessionId are left out altogether, including for responses saved before anonymous mode was turned on.
  • File uploads and signatures: each stored file gets a url that 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.