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

GEThttps://forms.hep.gg/api/v1/forms/public/:slugPublic
Fetch the schema of an active form by slug.

Returns 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.

Path
slug
stringrequired
The form's public slug. Lowercased server-side. A single path segment matching ^[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.

Response (data.form)
slug
stringoptional
The form's slug.
title
stringoptional
Form title.
description
string | nulloptional
Optional intro text.
status
stringoptional
Always active for a successful response.
login_modes
string[]optional
Which sign-in modes the form accepts: any of anonymous, hepgg, discord, sms_pin. Decides what your submit call must include.
requires_captcha
booleanoptional
When true, an anonymous submit (and every SMS PIN start) must include a Cloudflare Turnstile token.
email_field_id
string | nulloptional
For anonymous forms, the field_key of the field the owner designated as the submitter's email. Send the submitter's email under that key in data.
open_at
string | nulloptional
ISO timestamp before which the form is not yet open. Submitting earlier returns 403.
close_at
string | nulloptional
ISO timestamp after which the form is closed. Submitting later returns 403.
submission_cap
number | nulloptional
Maximum total submissions. Once reached, submitting returns 403.
allow_embed
booleanoptional
When true, the page may be iframed with ?embed=1.
allow_save_continue
booleanoptional
When true, the partial-state endpoints are available. See Save and continue.
pages
object[]optional
Ordered pages. Each page has id, 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
field_key
stringoptional
The stable key you use as the data-object key when submitting. This is NOT the field id. Placeholders in owner actions also use this key as {{field_key}}.
label
stringoptional
Display label.
sublabel
string | nulloptional
Optional secondary label.
description
string | nulloptional
Optional help text.
type
stringoptional
One of the field types below. Determines the value type you submit.
required
booleanoptional
When true, a blank value fails validation.
options
string[] | nulloptional
Allowed choices for DROPDOWN, RADIO, and MULTI_SELECT.
validation
object | nulloptional
Optional constraints. May include minLength, maxLength, pattern (text), min, max (number), and customError.
scale_min
number | nulloptional
Lower bound for LINEAR_SCALE.
scale_max
number | nulloptional
Upper bound for LINEAR_SCALE.
curl
curl https://forms.hep.gg/api/v1/forms/public/my-form
200 response (shape)
{
  "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:

404
{ "ok": false, "error": "Form not found or not active" }

Submit a response

POSThttps://forms.hep.gg/api/v1/forms/public/:slug/submitPublic
Submit a completed form.

Enforces, 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.

Body
data
objectrequired
An object keyed by each field's field_key. Values are typed per field (see Value types).
turnstileToken
stringoptional
Required for an anonymous submitter (no session, no SMS PIN). The Cloudflare Turnstile response token. Not needed for signed-in or SMS-PIN submitters.
smsPinToken
stringoptional
For sms_pin forms: the token returned by /sms/verify. Its presence both satisfies the SMS PIN login mode and exempts the submit from captcha.
smsPinPhone
stringoptional
For sms_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 includesWhat the submit needs
anonymousturnstileToken (when requires_captcha). Put the submitter's email under the email_field_id key in data.
hepgg or discordAn active .hep.gg session cookie on the request. No token in the body.
sms_pinsmsPinToken from the SMS PIN handshake, plus smsPinPhone.

If none of the form's modes are satisfied, the submit returns 401.

curl
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>"
      }'
200 response
{ "ok": true, "data": { "submissionId": "01J..." } }

Value types

The value you put under each field_key depends on the field type.

Per field type
SHORT_TEXT / LONG_TEXT / EMAIL
stringoptional
A string. EMAIL must look like an email. Optional validation.minLength, maxLength, pattern apply.
NUMBER
numberoptional
A number (a numeric string is accepted and coerced). Optional validation.min / max apply.
DROPDOWN / RADIO
stringoptional
A single string that must be one of the field's options.
MULTI_SELECT
string[]optional
An array of strings, each one of the field's options. Use [] for none.
CHECKBOX
booleanoptional
A boolean. A required checkbox must be true to pass.
DATE
stringoptional
A date string (the renderer sends an HTML date input value, YYYY-MM-DD).
TIME
stringoptional
A time string (the renderer sends an HTML time input value, HH:MM).
LINEAR_SCALE
numberoptional
A whole number between the field's scale_min and scale_max inclusive.
SECTION_BREAK
optional
Display only. Not a data field; send nothing for it.

A 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.

StatusWhenExample error
400Captcha rejectedCaptcha verification failed.
400Field validation failedSome fields failed validation (with details.fieldErrors)
401Form requires sign-in and the request had no satisfying modeThis form requires sign-in. Sign in and try again.
403Before open_atThis form isn't open yet.
403After close_atThis form has closed.
403submission_cap reachedThis form has reached its submission cap.
404Slug missing or form not activeForm not found or not active
429Per-IP hourly rate limitToo many submissions from this connection. Try again later.

On a validation failure, details.fieldErrors maps each failing field_key to a message:

400 validation
{
  "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.