Public API
Read a form's schema and submit responses over the public JSON API.
Public API
Two endpoints cover the common case: read a form's schema, then submit a response. Both are public (no key, no token) and answer on https://forms.hep.gg and https://hep.gg.
Read a form's schema
https://forms.hep.gg/api/v1/forms/public/:slugPublicReturns the structure you need to render the form or build a custom client: its title, the login modes it accepts, whether a captcha is required, and the page/row/field layout. Only forms with status active resolve; anything else returns 404. Each call increments the form's view count.
slug^[a-z0-9][a-z0-9-]{1,79}$.The fields below are what a client needs to render and submit the form. The response is a single form object; render from these and ignore anything else.
slugtitledescriptionstatusactive for a successful response.login_modesanonymous, hepgg, discord, sms_pin. Decides what your submit call must include.requires_captchaemail_field_idfield_key of the field the owner designated as the submitter's email. Send the submitter's email under that key in data.open_atclose_atsubmission_capallow_embed?embed=1.allow_save_continuepagesid, page_index, title, description, and rows[]. Each row has fields[]. See Fields.The schema also carries internal bookkeeping (timestamps, counters, branch rules, and the owner's delivery configuration). Build your client only from the fields documented above; treat the rest as opaque and do not depend on it.
Fields
Each field inside pages[].rows[].fields[] carries:
field_keyid. Placeholders in owner actions also use this key as {{field_key}}.labelsublabeldescriptiontyperequiredoptionsDROPDOWN, RADIO, and MULTI_SELECT.validationminLength, maxLength, pattern (text), min, max (number), and customError.scale_minLINEAR_SCALE.scale_maxLINEAR_SCALE.curl https://forms.hep.gg/api/v1/forms/public/my-formconst r = await fetch("https://forms.hep.gg/api/v1/forms/public/my-form");
const { ok, data } = await r.json();
if (!ok) throw new Error("Form not found or not active");
const form = data.form;{
"ok": true,
"data": {
"form": {
"slug": "my-form",
"title": "Beta signup",
"description": "Tell us about yourself.",
"status": "active",
"login_modes": ["anonymous"],
"requires_captcha": true,
"email_field_id": "your_email",
"open_at": null,
"close_at": null,
"submission_cap": null,
"allow_embed": true,
"allow_save_continue": false,
"pages": [
{
"id": "pg_1",
"page_index": 0,
"title": "About you",
"rows": [
{
"fields": [
{ "field_key": "your_name", "label": "Name", "type": "SHORT_TEXT", "required": true },
{ "field_key": "your_email", "label": "Email", "type": "EMAIL", "required": true }
]
}
]
}
]
}
}
}A missing or non-active form returns:
{ "ok": false, "error": "Form not found or not active" }Submit a response
https://forms.hep.gg/api/v1/forms/public/:slug/submitPublicEnforces, in order: the open/close window, the submission cap, captcha (anonymous submitters only), the per-IP hourly rate limit, the login-mode policy, and per-field validation. On success the submission is persisted and the owner's delivery actions run in the background.
datafield_key. Values are typed per field (see Value types).turnstileTokensmsPinTokensms_pin forms: the token returned by /sms/verify. Its presence both satisfies the SMS PIN login mode and exempts the submit from captcha.smsPinPhonesms_pin forms: the phone number used during verification, recorded as the submitter's phone.You never send submitter identity directly. It is snapshotted server-side from the session (hep.gg user, email, Discord), the anonymous email field, or the verified SMS phone.
Choosing what to send
Form login_modes includes | What the submit needs |
|---|---|
anonymous | turnstileToken (when requires_captcha). Put the submitter's email under the email_field_id key in data. |
hepgg or discord | An active .hep.gg session cookie on the request. No token in the body. |
sms_pin | smsPinToken from the SMS PIN handshake, plus smsPinPhone. |
If none of the form's modes are satisfied, the submit returns 401.
curl -X POST https://forms.hep.gg/api/v1/forms/public/my-form/submit \
-H "Content-Type: application/json" \
-d '{
"data": {
"your_name": "Ada",
"your_email": "ada@example.com",
"team_size": 4,
"interests": ["api", "webhooks"],
"agree_tos": true,
"rating": 5
},
"turnstileToken": "<cf-turnstile-response>"
}'const res = await fetch(
"https://forms.hep.gg/api/v1/forms/public/my-form/submit",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: {
your_name: "Ada",
your_email: "ada@example.com",
team_size: 4,
interests: ["api", "webhooks"],
agree_tos: true,
rating: 5,
},
turnstileToken: "<cf-turnstile-response>",
}),
},
);
const body = await res.json();
// { ok: true, data: { submissionId: "..." } }{ "ok": true, "data": { "submissionId": "01J..." } }Value types
The value you put under each field_key depends on the field type.
SHORT_TEXT / LONG_TEXT / EMAILEMAIL must look like an email. Optional validation.minLength, maxLength, pattern apply.NUMBERvalidation.min / max apply.DROPDOWN / RADIOoptions.MULTI_SELECToptions. Use [] for none.CHECKBOXtrue to pass.DATEYYYY-MM-DD).TIMEHH:MM).LINEAR_SCALEscale_min and scale_max inclusive.SECTION_BREAKA blank value is undefined, null, "", or []. Omitting an optional field's key is fine; omitting a required field fails validation.
Errors
All errors use the standard envelope. The status tells you the category.
| Status | When | Example error |
|---|---|---|
| 400 | Captcha rejected | Captcha verification failed. |
| 400 | Field validation failed | Some fields failed validation (with details.fieldErrors) |
| 401 | Form requires sign-in and the request had no satisfying mode | This form requires sign-in. Sign in and try again. |
| 403 | Before open_at | This form isn't open yet. |
| 403 | After close_at | This form has closed. |
| 403 | submission_cap reached | This form has reached its submission cap. |
| 404 | Slug missing or form not active | Form not found or not active |
| 429 | Per-IP hourly rate limit | Too many submissions from this connection. Try again later. |
On a validation failure, details.fieldErrors maps each failing field_key to a message:
{
"ok": false,
"error": "Some fields failed validation",
"details": {
"fieldErrors": {
"your_email": "Email must be a valid email",
"rating": "Rating is above the maximum"
}
}
}A 429 response carries a Retry-After header (seconds). Wait that long before retrying. The per-IP hourly limit is set by the form owner (rate_limit_per_ip_per_hour); when the owner leaves it unset there is no submit rate limit.