HelloForms

API & webhooks reference

Everything you need to read forms, pull submissions and receive signed events. Keys are managed in API keys and destinations in form webhooks.

Authentication

Every request needs a key issued to your account. Keys start with hf_live_ and are shown once at creation — we store only a SHA-256 hash. Send it as a bearer token (or in X-API-Key).

curl https://helloforms.net/api/public/v1/forms \
  -H "Authorization: Bearer hf_live_ab12cd34_your_secret_here"

Scopes

forms:read

Read forms

List your forms and read a single form with its fields.

forms:write

Create and update forms

Change a form's title, description or published status.

submissions:read

Read submissions

List and read submissions, including answers.

submissions:write

Create submissions

Submit a response to one of your published forms.

Interactive console

Paste one of your keys and run the examples below against your own account. Requests go directly from your browser and the response is shown inline.

Try it

Run real requests against your account.

Sent straight from your browser to this site — never stored on our servers. Create a key.

Your forms, newest first.

GET
/api/public/v1/forms?limit=5
needs forms:read

Requires the “Read forms” scope on the key you paste above.

Endpoints

GET
/api/public/v1/forms
forms:read

Lists your forms, newest first. Supports ?limit= (1–100) and ?status=.

{
  "data": [
    {
      "id": "8f1c…",
      "title": "Contact us",
      "slug": "contact-us",
      "status": "published",
      "submission_count": 42,
      "created_at": "2026-01-04T10:22:11.000Z"
    }
  ]
}
GET
/api/public/v1/forms?id=<form-id>
forms:read

Returns a single form together with its ordered field definitions.

PATCH
/api/public/v1/forms
forms:write

Updates a form's title, description or status. Publishing still respects your plan's published-form limit.

curl -X PATCH https://helloforms.net/api/public/v1/forms \
  -H "Authorization: Bearer hf_live_…" \
  -H "Content-Type: application/json" \
  -d '{"id":"8f1c…","status":"published"}'
GET
/api/public/v1/submissions?form_id=<form-id>
submissions:read

Lists submissions newest-first for one of your forms — or across every form when form_id is omitted. Use ?limit= (1–200, default 50) and ?cursor= to walk the whole history. Add ?id=<submission-id> for one submission with its answers keyed by field label.

{
  "data": [
    { "id": "3d90…", "form_id": "8f1c…", "form_title": "Contact us",
      "is_complete": true, "submitted_at": "2026-02-11T09:14:03.000Z" }
  ],
  "count": 50,
  "has_more": true,
  "next_cursor": "eyJ0cyI6IjIwMjYt…"
}
POST
/api/public/v1/submissions
submissions:write

Creates a submission against a published form — the same path your public form uses, so quotas, required fields and webhooks all apply.

curl -X POST https://helloforms.net/api/public/v1/submissions \
  -H "Authorization: Bearer hf_live_…" \
  -H "Content-Type: application/json" \
  -d '{"form_id":"8f1c…","answers":{"email":"a@example.com"}}'

Pagination

Submission lists use cursor (keyset) pagination, so new responses arriving while you crawl never push rows onto a page you already read. Each response carries has_more and next_cursor, and a Link: <…>; rel="next" header with the ready-made URL. Cursors are opaque — pass them back untouched and never build one yourself.

cursor=""
while :; do
  res=$(curl -s "https://helloforms.net/api/public/v1/submissions?limit=100&cursor=$cursor" \
    -H "Authorization: Bearer hf_live_…")
  echo "$res" | jq '.data[]'
  [ "$(echo "$res" | jq -r '.has_more')" = "true" ] || break
  cursor=$(echo "$res" | jq -r '.next_cursor')
done

limit accepts 1–200 (default 50); out-of-range values are clamped. A cursor we did not issue returns 400 invalid_request — drop it to start from the newest submission again.

Rate limits

Limits are counted per API key in a rolling window. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; once exhausted you get 429 rate_limited with Retry-After. Reads are cheap — prefer a larger limit with cursors over hammering small pages, and back off on 429 instead of retrying immediately.

Errors

Failures return { "error": { "code": …, "message": … } } with these codes:

CodeHTTPMeaning
missing_key401Provide your API key as `Authorization: Bearer hf_live_…`.
invalid_key401That API key is not recognised.
revoked_key401That API key was revoked.
expired_key401That API key has expired.
insufficient_scope403This key does not include the required scope.
plan_required403API access is not included on your current plan.
not_found404No such resource for this account.
invalid_request400The request could not be understood.
rate_limited429Too many requests — slow down and retry.
server_error500Something went wrong on our side.

Webhooks

Add an HTTPS destination and we POST JSON for the events you choose. Return any 2xx quickly; 5xx and 429 responses are retried.

submission.created

New submission

Sent as soon as a response is saved, with all answers.

submission.payment_completed

Submission paid

Sent when a submission's payment is confirmed.

form.published

Form published

Sent when one of your forms goes live.

Verifying the signature

Each request carries X-HelloForms-Signature, X-HelloForms-Event and X-HelloForms-Delivery. Compute the HMAC over <timestamp>.<raw body> and reject anything older than 300 seconds.

import { createHmac, timingSafeEqual } from "crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(parts.v1 ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

Retries

Failed attempts back off over 1, 5, 15, 60, 180 minutes and beyond, up to the destination's attempt limit. Any delivery can be replayed by hand from the delivery log.