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:readRead forms
List your forms and read a single form with its fields.
forms:writeCreate and update forms
Change a form's title, description or published status.
submissions:readRead submissions
List and read submissions, including answers.
submissions:writeCreate 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.
/api/public/v1/forms?limit=5Requires the “Read forms” scope on the key you paste above.
Endpoints
/api/public/v1/formsLists 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"
}
]
}/api/public/v1/forms?id=<form-id>Returns a single form together with its ordered field definitions.
/api/public/v1/formsUpdates 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"}'/api/public/v1/submissions?form_id=<form-id>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…"
}/api/public/v1/submissionsCreates 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')
donelimit 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:
| Code | HTTP | Meaning |
|---|---|---|
missing_key | 401 | Provide your API key as `Authorization: Bearer hf_live_…`. |
invalid_key | 401 | That API key is not recognised. |
revoked_key | 401 | That API key was revoked. |
expired_key | 401 | That API key has expired. |
insufficient_scope | 403 | This key does not include the required scope. |
plan_required | 403 | API access is not included on your current plan. |
not_found | 404 | No such resource for this account. |
invalid_request | 400 | The request could not be understood. |
rate_limited | 429 | Too many requests — slow down and retry. |
server_error | 500 | Something 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.createdNew submission
Sent as soon as a response is saved, with all answers.
submission.payment_completedSubmission paid
Sent when a submission's payment is confirmed.
form.publishedForm 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.

