Forest Logo
search
package_2

google-form-kit

By @hakochanjp

Roblox

Mirrored

google-form-kit

Fetch the structure of a public Google Form from Roblox (Luau, server-side) and submit answers to it, with an optional DataStore-backed submission-policy layer (Gate) for rate limiting.

The core parses the FB_PUBLIC_LOAD_DATA_ blob embedded in a form's public HTML — no Google authentication, no Apps Script, no API key. Only forms that anyone with the link can open and answer are supported.

  • realm: shared — the package can be required from both server and client. The only work done at require time is acquiring the HttpService handle (client-safe); no HTTP or DataStore access happens until a function is actually called. Anything that touches HttpService or DataStoreService (Kit.fetch, form:submit, Kit.createGate, all Gate methods) is server-only and raises a hard error() if called from the client.
  • Zero runtime dependencies.
  • Reading responses back out of the linked spreadsheet is out of scope.

Installation

Add to your project's wally.toml:

[dependencies]
GoogleFormKit = "hakochanjp/google-form-kit@0.2.0"
wally install

This makes Packages.GoogleFormKit available; require it from a Script or ModuleScript.

Quick Start (server)

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kit = require(ReplicatedStorage.Packages.GoogleFormKit)

-- Kit.fetch yields (HttpService:GetAsync under the hood). Server-only.
local form, err = Kit.fetch("https://forms.gle/xxxx")
if not form then
    warn(err.code, err.message)
    return
end

print(form.title)          -- string
print(form.description)    -- string?
for _, question in form.questions do
    print(question.title, question.type, question.required)
end

-- form:submit yields (HttpService:RequestAsync POST). Server-only.
local ok, submitErr = form:submit({
    ["Radio button"] = "Yes",                    -- keyed by question title
    ["Checkboxes"] = { "Option 1", "Option 3" },  -- checkbox answers are ARRAYS, never dictionaries
    ["Short answer"] = "some text",
    ["Linear scale"] = 4,                         -- numbers are accepted (stringified internally)
    ["entry.632003319"] = "Option 2",             -- entry ID keys are also accepted
})

-- Questions with a built-in "Other" choice (question.hasOther == true) also accept
-- an { other = "free text" } value:
local ok2, err2 = form:submit({
    ["Radio button"] = { other = "something else" },              -- radio: Other instead of a choice
    ["Checkboxes"] = { "Option 1", { other = "my own answer" } }, -- checkbox: mix choices with at most ONE other
})
if not ok then
    warn(submitErr.code, submitErr.message)
end
  • Answer keys accept either the exact question title or the literal "entry.<id>" string. If a title is duplicated across questions, form:submit/form:validate return a ValidationFailed error asking you to use the entry.<id> form instead.
  • Fallible calls (Kit.fetch, form:validate, form:submit, gate:submitFor, gate:canSubmit) return Lua's value, err two-value style; err == nil on success. The plain constructors/getters (Kit.fromSchema, form:getSchema) cannot fail and return a single value. Kit.createGate also returns a single value, but it is not failure-free: it hard-errors (not an err return) if called from the client — see the realm note above.
  • The empty string ("") and the empty array ({}) both mean "unanswered" — required-question checks treat them as missing.

The "Other" choice (hasOther)

Radio and checkbox questions can carry Google Forms' built-in "Other" choice with a free-text field. The parser reports it as question.hasOther == true (the "Other" pseudo-choice is not included in question.choices), and hasOther round-trips through form:getSchema() / Kit.fromSchema — on plain schemas without the field, nil simply means false.

  • Radio: pass "Choice A" or { other = "free text" }.
  • Checkbox: pass an array mixing choice strings with at most one { other = "free text" } item, e.g. { "Choice A", { other = "free text" } }.
  • other must be a string. An empty other ("") counts as "unanswered", consistent with the "" / {} convention above — a required question is satisfied by at least one regular choice or a non-empty other text.
  • Passing { other = ... } to a question whose hasOther is not true is a ValidationFailed. Dropdowns can never have an "Other" choice (a Google Forms restriction), so they always reject it.
  • On the wire, an "Other" answer is encoded the way the real form does it: entry.<id>=__other_option__ plus entry.<id>.other_option_response=<text>. Answers that use only regular choices produce byte-identical bodies to v0.1.x.
  • Migration note: v0.1.x mistakenly parsed the "Other" pseudo-choice as an empty string inside choices. 0.2.0 removes it and reports hasOther instead, so on forms that have an "Other" choice, #question.choices is one smaller than in v0.1.x.

Supported URL shapes

Kit.fetch normalizes the URL before requesting it. All of these work as long as the form is public:

ShapeExample
forms.gle short linkhttps://forms.gle/AbCdEfGh12345
viewform URL, with or without query stringhttps://docs.google.com/forms/d/e/<id>/viewform?usp=sf_link
bare /d/e/<id> (no /viewform suffix)https://docs.google.com/forms/d/e/<id>
edit URL /d/<docId>/edithttps://docs.google.com/forms/d/<docId>/edit

forms.gle links resolve via HttpService's own redirect following. /d/e/<id> and /d/<docId>/edit are both rewritten to .../viewform internally. In every case, the target form must be public (no login required) — a private, org-restricted, or "not accepting responses" form usually returns ParseFailed instead of a parsed schema (the HTML loads but has no FB_PUBLIC_LOAD_DATA_ blob), but may instead surface as HttpError if Google rejects the request itself with a non-2xx response.

Client UI flow

The recommended flow keeps HTTP and DataStore access on the server while still letting the client build a form UI and give immediate feedback:

-- [server] fetch once, then hand a plain-data schema to the client
local form = Kit.fetch(url)
local schema = form:getSchema()   -- { title, description, questions } — plain data, no functions, no submitUrl
remoteEvent:FireClient(player, schema)

-- [client] rebuild a Form from the schema and build UI from it
local clientForm = Kit.fromSchema(schema)
for _, question in clientForm.questions do
    -- build UI per question.type
end

-- [client] validate locally before sending, for instant feedback
local ok, err = clientForm:validate(answers)
if not ok then
    -- show err.message near the offending field
    return
end
remoteEvent:FireServer(answers)
-- clientForm:submit(...) would error("...server-only...") if called here
-- [server] receive answers and re-validate before actually submitting.
-- Never trust client-side validation alone — the client is not authoritative.
remoteEvent.OnServerEvent:Connect(function(player, answers)
    local ok, err = gate:submitFor(player, answers)
    if not ok then
        -- ok is false and err.code is one of the Gate error codes below
    end
end)

form:getSchema() returns { title, description, questions } only — it is client-safe by design and never needs sanitizing before you FireClient it. submitUrl (the formResponse POST endpoint) is deliberately excluded: it never appears in the schema, so it cannot be read off the wire by an exploiter inspecting RemoteEvent/RemoteFunction traffic. If you build a FormSchema by hand for server-side use (e.g. to call form:submit without going through Kit.fetch), submitUrl is optional on the type but required in practice for that path — Gate derives its DataStore scope hash from it, and form:submit posts to it; a Form without one raises a clear error naming submitUrl if you try to submit through it.

The kit does not ship any RemoteEvent/RemoteFunction. Wiring the client→server request path (naming, throttling the remote itself, FireServer/OnServerEvent vs RemoteFunction) is entirely up to the game.

Security note: the Google Form URL is kept server-side only and is never included in getSchema()'s output. Keep FORM_URL (or wherever you store the fetch URL) in a server-only script (ServerScriptService, etc.) — never in ReplicatedStorage. Because a public Google Form accepts submissions from anyone who knows its URL, keeping that URL secret from clients is what makes Gate's rate limiting actually effective; if the URL leaked to the client, an exploiter could POST to Google directly and bypass Gate entirely.

Always send only form:getSchema()'s return value to clients — never the Form instance itself. As defense in depth, the Form instance no longer even carries the URL as a table field (it's kept in an internal weak-keyed side table), so passing a whole Form through a remote by mistake would not leak it either — but that's a safety net, not a reason to do it; getSchema() is the documented and supported way to hand form data to a client.

Everything getSchema() does include — question titles, entry IDs, choice lists — is harmless to expose: none of it lets a client submit anywhere without the form URL, and none of it is secret about the form itself (anyone who opens the public form in a browser sees the same thing).

Kit.createGate also enforces this: it hard-errors (naming submitUrl in the message) unless the form holds a submitUrl internally — normally the Form returned by Kit.fetch (a hand-built schema that includes submitUrl passed to Kit.fromSchema also qualifies). A Form restored from getSchema() output has no URL, can never submit, and therefore can never be gated either. This check applies unconditionally, even if you inject a fake store for testing.

Submission policies (Gate)

Kit.createGate(form, policy?) wraps a Form with rate limiting and a submission window, backed by DataStoreService. It does not modify Form itself — the underlying form:submit stays a plain primitive.

local gate = Kit.createGate(form, {
    limit = "daily",
    window = {   -- optional; omit for "always accepting"
        startsAt = DateTime.fromIsoDate("2026-08-01T00:00:00+09:00"),
        endsAt   = DateTime.fromIsoDate("2026-08-31T23:59:59+09:00"),
    },
    successCooldownSeconds = 600,
    requestIntervalSeconds = 60,
    dailyResetOffsetHours = 9,
    dataStoreName = "GoogleFormKit",
})

-- Call from your own remote's receive handler.
local ok, err = gate:submitFor(player, answers)

-- Pre-check for UI purposes; issues no HTTP or DataStore write, only a read.
local canSubmit, err = gate:canSubmit(player)  -- err.retryAfterSeconds has the remaining wait, if applicable

gate:canSubmit's underlying DataStore read is cached per player for up to 30 seconds, per server — polling it in a loop does not hammer the DataStore, but it also means the verdict can be up to 30 seconds stale (e.g. a success recorded on another server, or via submitFor on this one, may not be reflected immediately).

Policy options

OptionDefaultMeaning
limit"unlimited""once" (one successful submission ever, per user) / "daily" (one per JST-offset day) / "unlimited"
windownil (no restriction){ startsAt: DateTime?, endsAt: DateTime? } — accept submissions only within this range
successCooldownSeconds600 (10 minutes)Minimum time after a successful submission before another is allowed
requestIntervalSeconds60 (1 minute)Minimum time between submitFor calls from the same player
dailyResetOffsetHours9 (JST midnight)UTC offset in hours used to compute the "daily" boundary
dataStoreName"GoogleFormKit"DataStore name Gate reads/writes success records in

Requirements and cross-server behavior

  • Gate needs DataStoreService — enable Studio Access to API Services to test in Studio, and the game must have DataStores available in production (they're on by default for published places).
  • limit and successCooldownSeconds are enforced via DataStore and therefore apply cross-server (one Roblox universe, any number of servers).
  • requestIntervalSeconds is tracked in per-server memory only — a player switching servers resets their 1-minute throttle window.
  • If the DataStore read (the once/daily/cooldown check before submitting) fails, Gate fails safe (closed): the submission is refused with DataStoreError and no HTTP request is sent, rather than risk letting a limit slip through.
  • If the DataStore write (recording success, right after form:submit already reached Google) fails, Gate does not fail closed — it cannot: the answer has already been submitted and cannot be un-sent. gate:submitFor still returns true (the submission genuinely succeeded), only warn()s "GoogleFormKit: success record write failed: ...", and the success record is left stale/missing. In this case the user's once/daily/cooldown limit for this submission may not stick, and they could be able to submit again before it should be allowed.
  • DataStore key layout: store dataStoreName (default "GoogleFormKit"), scope f_<hex8> where <hex8> is a djb2 hash of the form's submit URL (keeps the scope short and separates records per form), key tostring(player.UserId).

Error codes

Every fallible call returns value, err where err = { code: string, message: string, retryAfterSeconds: number? }.

CodeRaised byMeaningretryAfterSeconds
HttpErrorfetch, submitHttpService failed: HTTP requests disabled, connection failure, or a non-2xx response
ParseFailedfetchFB_PUBLIC_LOAD_DATA_ was not found in the HTML (private/login-required/closed form, or Google changed the internal format)
ValidationFailedsubmit, validate, GateAnswer violates the validation rules (see below)
ThrottledGate.submitFor / Gate.canSubmitCalled again before requestIntervalSeconds has elapsed since the last callsubmitFor: the full interval (calling submitFor re-arms the timer even on failure, so "wait this long from now" is always correct). canSubmit: the actual remaining time (it does not re-arm the timer)
OutsideWindowGateCurrent time (DateTime.now(), UTC) is before window.startsAt or after window.endsAtseconds until startsAt, if before the window; absent if past endsAt
LimitReachedGatelimit = "once" already has a success record, or limit = "daily" already succeeded todayonce: nil (no time will ever fix it). daily: seconds until the later of the next JST(-offset) midnight or the cooldown ending, whichever is later — this avoids the case where the daily boundary passes but the cooldown is still active
CooldownGateLess than successCooldownSeconds has passed since the last successseconds remaining until the cooldown ends
DataStoreErrorGateThe DataStore read (limit/cooldown check, before submitting) failed; Gate refuses the submission rather than risk letting a limit slip through. Note: a failed DataStore write (recording success, after the answer already posted to Google) does not raise this code — submitFor still returns true and only warn()s, since the answer can't be un-sent

Validation rules (checked before any HTTP request)

RuleExample message
Required question left unansweredrequired question "Radio button" is missing
Unknown question title / entry IDunknown question "Rating"
Radio/dropdown: value not one of the choices"maybe" is not a choice of "Radio button"
Checkbox: not an array, or contains a value not in the choicesanswer for "Checkboxes" must be an array of choices
Linear scale: out of range or not a number6 is out of scale 1..5 for "Linear scale"
{ other = ... } on a question without hasOther (incl. every dropdown)"Radio button" does not have an "Other" option
other is not a string, or the table has extra keysanswer for "Radio button" must be a choice string or { other = "text" }
Checkbox: more than one { other = ... } item in the arrayanswer for "Checkboxes" may contain at most one { other = "text" } item
unsupported question type answeredquestion type of "..." is not supported
  • Checkbox answers must be arrays ({ "Option 1", "Option 3" }), never dictionaries keyed by option — a dictionary/table with non-sequential keys fails validation.
  • Free-text answers accept any string; an empty string counts as "unanswered", not an empty answer.
  • Linear scale accepts both a Lua number and a numeric string (4 or "4"); the value must be an integer within scale.min..scale.max.

Limitations

  • HTTP Requests must be enabled for the experience (Game Settings → Security → Allow HTTP Requests). Kit.fetch and form:submit both require it.
  • Public forms only. Any form that requires sign-in, is limited to an organization, or has closed responses returns ParseFailed from Kit.fetch.
  • Unsupported question types. Grid, date, and time questions are not parsed into a usable type — each appears in form.questions as a single entry with type = "unsupported" and cannot be answered (attempting to do so is a ValidationFailed). If such a question is marked required on the live form, there is no way to supply a valid answer for it through this kit, so the form cannot be submitted through this kit at all.
  • submit({}) on a form with no required questions still POSTs. If a form happens to have zero required questions, calling form:submit({}) (or gate:submitFor(player, {})) passes validation and sends an empty response to Google — it is not rejected client-side just because nothing was answered.
  • Section headers are dropped, not "unsupported". A section/page-break item has no answer field of its own in the underlying data, so the parser silently drops it — it never appears in form.questions at all (not even as unsupported).
  • Section branching logic is ignored. All questions from every section are flattened into a single form.questions list in document order, and every required question across all sections is enforced by validate/submit/Gate regardless of which branch a real respondent would have seen in the browser. A form that uses "go to section based on answer" branching may therefore be effectively un-submittable through this kit if different branches have mutually exclusive required questions.
  • FB_PUBLIC_LOAD_DATA_ is an undocumented, internal Google format. It is not a published API and could change without notice, which would surface as ParseFailed on forms that used to work. Parser.luau is kept as an isolated module specifically to contain the blast radius of such a change.
  • File-upload questions are not supported, and this package does not read responses back out of the linked spreadsheet.

Package Details

Install command (Click to copy)


Version

0.2.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.