What you will take away
There are two different things called JSON here: a file per submission in your Drive, and a read API over your records.
The API reads your Google Sheet, so a row you correct by hand appears in the JSON. Most form backends cannot do that.
You choose how fresh the response is, and the trade is stated rather than hidden.
Reading form submissions as JSON
You can read your form submissions as JSON in two ways, and they answer different needs: a JSON file written into your Google Drive once per submission, and a read endpoint that returns many records at once for a page or a script to consume. The second is what most people mean, and it is the one with the useful property — it reads your Google Sheet, not a private copy of it.
That distinction is the whole reason this page exists. Every form backend offers a submissions API. What they return is the row as their database recorded it at the moment it arrived, which is not necessarily what you believe about that submission today.
The two shapes, and which one you want
A JSON file per submission
Set a workflow's output to JSON and each accepted response is written into that workflow's Drive folder as its own file, named after the workflow and the submission reference. The contents are the submitted fields plus the submission metadata — the reference, when it was accepted, when it was written, and the timezone those timestamps are in.
This is the right choice when something downstream consumes files: a nightly import that walks a folder, an archive, a build step that reads a directory. It is the wrong choice when you want to ask a question like "the last fifty enquiries" — that is a folder listing and a lot of small reads.
A read endpoint over your records
The other shape is a request that returns many records at once:
GET https://webtzm.com/fetch/YOUR_FORM_ID?limit=50&cursor=0
Authorization: Bearer wtzm_sk_…
and the response:
{
"records": [
{ "webtzm_id": "…", "email": "…", "submitted_at": "…" }
],
"next_cursor": 50,
"freshness": { "mode": "standard", "cached_at": 1756000000, "max_age_seconds": 60 }
}
Two kinds of key exist because two kinds of caller exist. A secret key stays on a server and is sent as a bearer token. A publishable key may be used from a browser and is restricted to origins you list, so a key lifted from your page source is useless anywhere else.
The difference that actually matters
This endpoint reads your Google Sheet. That sounds like an implementation detail and it is the single most useful thing about it.
Consider what happens after a submission arrives. Somebody mistypes their email and you fix it in the Sheet. A duplicate comes in and you mark it. You add a status column and work through the rows, setting each one to contacted. Ordinary operational work, done where the data already lives.
With a form backend's submissions API, none of that exists. Their API returns the payload their server received. Your corrections live in a spreadsheet they have never seen, so the API and the truth drift apart from the first edit, and the more you actually use your data the further apart they get.
Here the Sheet is the source of truth and the endpoint reads it, so the corrected address is the address the API returns, and the status column you invented last week is a field in the JSON this week. You did not configure that. It follows from reading the thing you were already working in.
The cost is a cache, and you pick it per workflow with the trade stated rather than guessed:
Live — no caching. Every request reads Google, which counts against your quota and takes the strictest rate limit. For a dashboard that must show the newest row the moment it lands.
Standard — sixty seconds, and the right answer for almost everything. A row you edit by hand can take up to a minute to appear.
Economy — five minutes. The cheapest and most resilient, and the most visibly stale. For high-traffic pages and static site builds.
In every mode the Sheet remains the source of truth, so an edit always appears eventually. Serving from the submission records instead would have been faster and is wrong: those records cannot see a manual edit at all, which reads to an owner as a bug rather than as a cache.
Paging, and reading only what is new
Ask for a page, then follow the cursor the response hands back until it stops coming.
async function allRecords(formId, key) {
const records = [];
let cursor = 0;
while (cursor !== null && cursor !== undefined) {
const url = new URL(`https://webtzm.com/fetch/${formId}`);
url.searchParams.set("limit", "50");
url.searchParams.set("cursor", String(cursor));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${key}` }
});
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const page = await response.json();
records.push(...page.records);
cursor = page.next_cursor ?? null;
}
return records;
}
For a job that runs repeatedly, do not page through everything each time. Pass since with a date and read only what arrived after it:
const url = new URL(`https://webtzm.com/fetch/${formId}`);
url.searchParams.set("since", "2026-08-01");
The identifier on each record is worth understanding rather than ignoring. It is the handle for that submission, and it is what an update or a deletion has to name. If you plan to let someone amend what they sent, keep it — reconciling on an email address works until two people share one, and then it stops working quietly.
The failures worth handling
Three responses mean genuinely different things and deserve different code.
403 — the key is revoked, or the request came from an origin the publishable key does not list. Neither is retryable. Check the origin list first; it is the usual cause when the same key works from a terminal and not from the page.
429 — rate or quota exhaustion. Back off and retry. If you see it steadily rather than in bursts, the fix is usually a longer cache mode rather than more retries.
402 — this workflow's plan does not include the read endpoint. Not retryable, and not something a code change fixes. Pricing is on the pricing page.
A read endpoint is also a place to be careful about what leaves your account. Give a page the publishable key with its origins listed, keep the secret key on a server, and remember that a record can carry whatever the form collected — including things you would rather not render into public HTML.
When a form backend is the better answer
Use a dedicated form backend — Formcarry, Web3Forms, or one of their peers — if what you want is a submissions API and nothing else. They are built for exactly this, the endpoint is the product rather than a read side attached to something else, and you do not have to connect a Google account at all. If nobody on your team will ever open a spreadsheet, the Sheet in the middle is a step you are not using.
Write your own handler if the submission needs to do something at the moment it arrives — charge a card, check stock, call another system and branch on the answer. A read endpoint is a read endpoint; it is not a webhook and it is not a place to put logic.
Take this route when the same submissions need to be worked on by people and read by software: reviewed, corrected and annotated in a spreadsheet by whoever owns the process, and pulled as JSON by a page or a script that should see those corrections rather than a frozen copy of what was originally typed.
Read your own records, corrections included.
Point one page at a form you already collect, and watch a fix made in the spreadsheet show up in the JSON.
Build your first Webtzm delivery →Frequently asked questions
What is the difference between the JSON output and the read endpoint?
The JSON output writes one file per submission into your Drive folder, which suits something that consumes files. The read endpoint returns many records in one request, which suits a page or a script asking for the most recent submissions.
Does the endpoint return corrections I made in the spreadsheet?
Yes. It reads your Google Sheet rather than a private copy of the submission, so a row you fixed by hand is the row the API returns, and a column you added is a field in the response.
How fresh is the response?
You choose per workflow. Live does not cache and reads Google on every request. Standard caches for sixty seconds and suits almost everything. Economy caches for five minutes and is the cheapest and most resilient.
Can I call it from a browser?
Yes, with a publishable key, which is restricted to the origins you list. A secret key is for server-side use and is sent as a bearer token. A publishable key lifted from your page source does not work from anywhere else.
How do I read only what is new?
Pass a since parameter with a date and you get only the records that arrived after it. For a job that runs repeatedly this is much cheaper than paging through everything each time.
What does a 403 mean?
Either the key has been revoked or the request came from an origin the publishable key does not list. Neither is worth retrying. The origin list is the usual cause when the same key works from a terminal but not from a page.
