Start typing to search packages!
google-form-kit
By @hakochanjp
Roblox
Mirroredgoogle-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 berequired from both server and client. The only work done at require time is acquiring theHttpServicehandle (client-safe); no HTTP or DataStore access happens until a function is actually called. Anything that touchesHttpServiceorDataStoreService(Kit.fetch,form:submit,Kit.createGate, allGatemethods) is server-only and raises a harderror()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:validatereturn aValidationFailederror asking you to use theentry.<id>form instead. - Fallible calls (
Kit.fetch,form:validate,form:submit,gate:submitFor,gate:canSubmit) return Lua'svalue, errtwo-value style;err == nilon success. The plain constructors/getters (Kit.fromSchema,form:getSchema) cannot fail and return a single value.Kit.createGatealso returns a single value, but it is not failure-free: it hard-errors (not anerrreturn) 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" } }. othermust be a string. An emptyother("") counts as "unanswered", consistent with the""/{}convention above — a required question is satisfied by at least one regular choice or a non-emptyothertext.- Passing
{ other = ... }to a question whosehasOtheris nottrueis aValidationFailed. 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__plusentry.<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 reportshasOtherinstead, so on forms that have an "Other" choice,#question.choicesis 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:
| Shape | Example |
|---|---|
forms.gle short link | https://forms.gle/AbCdEfGh12345 |
viewform URL, with or without query string | https://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>/edit | https://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. KeepFORM_URL(or wherever you store the fetch URL) in a server-only script (ServerScriptService, etc.) — never inReplicatedStorage. Because a public Google Form accepts submissions from anyone who knows its URL, keeping that URL secret from clients is what makesGate's rate limiting actually effective; if the URL leaked to the client, an exploiter could POST to Google directly and bypassGateentirely.Always send only
form:getSchema()'s return value to clients — never theForminstance itself. As defense in depth, theForminstance no longer even carries the URL as a table field (it's kept in an internal weak-keyed side table), so passing a wholeFormthrough 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.createGatealso enforces this: it hard-errors (namingsubmitUrlin the message) unless theformholds asubmitUrlinternally — normally theFormreturned byKit.fetch(a hand-built schema that includessubmitUrlpassed toKit.fromSchemaalso qualifies). AFormrestored fromgetSchema()output has no URL, can never submit, and therefore can never be gated either. This check applies unconditionally, even if you inject a fakestorefor 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
| Option | Default | Meaning |
|---|---|---|
limit | "unlimited" | "once" (one successful submission ever, per user) / "daily" (one per JST-offset day) / "unlimited" |
window | nil (no restriction) | { startsAt: DateTime?, endsAt: DateTime? } — accept submissions only within this range |
successCooldownSeconds | 600 (10 minutes) | Minimum time after a successful submission before another is allowed |
requestIntervalSeconds | 60 (1 minute) | Minimum time between submitFor calls from the same player |
dailyResetOffsetHours | 9 (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). limitandsuccessCooldownSecondsare enforced via DataStore and therefore apply cross-server (one Roblox universe, any number of servers).requestIntervalSecondsis 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 withDataStoreErrorand no HTTP request is sent, rather than risk letting a limit slip through. - If the DataStore write (recording success, right after
form:submitalready reached Google) fails, Gate does not fail closed — it cannot: the answer has already been submitted and cannot be un-sent.gate:submitForstill returnstrue(the submission genuinely succeeded), onlywarn()s"GoogleFormKit: success record write failed: ...", and the success record is left stale/missing. In this case the user'sonce/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"), scopef_<hex8>where<hex8>is a djb2 hash of the form's submit URL (keeps the scope short and separates records per form), keytostring(player.UserId).
Error codes
Every fallible call returns value, err where err = { code: string, message: string, retryAfterSeconds: number? }.
| Code | Raised by | Meaning | retryAfterSeconds |
|---|---|---|---|
HttpError | fetch, submit | HttpService failed: HTTP requests disabled, connection failure, or a non-2xx response | — |
ParseFailed | fetch | FB_PUBLIC_LOAD_DATA_ was not found in the HTML (private/login-required/closed form, or Google changed the internal format) | — |
ValidationFailed | submit, validate, Gate | Answer violates the validation rules (see below) | — |
Throttled | Gate.submitFor / Gate.canSubmit | Called again before requestIntervalSeconds has elapsed since the last call | submitFor: 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) |
OutsideWindow | Gate | Current time (DateTime.now(), UTC) is before window.startsAt or after window.endsAt | seconds until startsAt, if before the window; absent if past endsAt |
LimitReached | Gate | limit = "once" already has a success record, or limit = "daily" already succeeded today | once: 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 |
Cooldown | Gate | Less than successCooldownSeconds has passed since the last success | seconds remaining until the cooldown ends |
DataStoreError | Gate | The 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)
| Rule | Example message |
|---|---|
| Required question left unanswered | required question "Radio button" is missing |
| Unknown question title / entry ID | unknown 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 choices | answer for "Checkboxes" must be an array of choices |
| Linear scale: out of range or not a number | 6 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 keys | answer for "Radio button" must be a choice string or { other = "text" } |
Checkbox: more than one { other = ... } item in the array | answer for "Checkboxes" may contain at most one { other = "text" } item |
unsupported question type answered | question 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 (
4or"4"); the value must be an integer withinscale.min..scale.max.
Limitations
- HTTP Requests must be enabled for the experience (Game Settings → Security → Allow HTTP Requests).
Kit.fetchandform:submitboth require it. - Public forms only. Any form that requires sign-in, is limited to an organization, or has closed responses returns
ParseFailedfromKit.fetch. - Unsupported question types. Grid, date, and time questions are not parsed into a usable type — each appears in
form.questionsas a single entry withtype = "unsupported"and cannot be answered (attempting to do so is aValidationFailed). 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, callingform:submit({})(orgate: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.questionsat all (not even asunsupported). - Section branching logic is ignored. All questions from every section are flattened into a single
form.questionslist in document order, and everyrequiredquestion across all sections is enforced byvalidate/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 asParseFailedon forms that used to work.Parser.luauis 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
Safe for commercial use
Automated license review — not legal advice.
