Cypress JSON Fixtures: Stub cy.intercept and Seed the Database
Most Cypress fixtures start as a JSON file someone wrote by hand:
users.json with two users, both active, both with short names. It
passes, because it was written to pass. Six months later the real API
returns a status field the fixture doesn't have, nobody has ever
rendered the page with zero users or a 45-character name, and the
detail page is stubbed with a user whose id isn't in the list.
This post replaces those hand-written files with generated Cypress
JSON fixtures, rendered once from a seeded template and committed. One
template produces the list response, a detail response per row with
matching ids, and planted rows for the states that break UIs. The same
template also seeds a real database through cy.task for the tests
that shouldn't be stubbed. Everything here is Cypress-specific. If your
suite runs on Playwright, the equivalent setup is covered in
Playwright test data.
Why hand-written Cypress fixtures drift
Hand-written fixtures go wrong in a few predictable ways:
- The shape drifts. The API gains a field, renames one or starts
returning
null. The fixture still has last quarter's shape, and the stubbed tests keep passing against a response the app no longer gets. - Only the happy path exists. Two active users with short names never exercise the empty state, the invited user who has never logged in, or the name that pushes the row's action menu off-screen.
- List and detail disagree.
users.jsonlists ids 1 and 2, whileuser.jsonfor the detail page has id 7 and a different name. A test that clicks a row and checks the heading can't be written honestly against that pair. - Nobody knows where the data came from. When a value looks odd, there's no way to tell whether it was chosen on purpose or typed in a hurry.
Generating the files fixes the first three if the template follows the API's real shape and plants the awkward rows. Committing the template next to the files fixes the fourth.
Three ways Cypress gets test data
Cypress gives a test three ways to get data, and they suit different jobs:
cy.fixture()loads a file fromcypress/fixtures(the defaultfixturesFolder) and yields it, parsed if it's JSON. Use it when the test needs to know the data, for example to assert that a heading shows the name from the file. The Cypress docs note that fixtures are loaded once, so overwriting a file in the middle of a run doesn't change whatcy.fixture()returns. Generate fixtures before the run, not from inside a test.cy.intercept()catches a network request from the app and can answer it with a static response, including{ fixture: "members/list.json" }for the body andstatusCodefor errors. Use it to stub the backend out completely. If you want the same stubs in a dev server and unit tests as well, a tool-independent layer such as MSW fits better; see mock API response data.cy.task()orcy.request()in a hook writes data to a real backend before the test runs.cy.request()works when the backend has a test-only seeding endpoint.cy.task()runs code in Node throughsetupNodeEvents, so it can talk to the database directly and use secrets that spec code shouldn't hold.
The first two use the same committed files. The third generates data at run time. Both come from one template, so a stubbed test and a real-backend test see the same kind of data.
Generate the Cypress JSON fixtures from one template
The example app has a members page in an admin console:
GET /api/members returns a list, and GET /api/members/:id returns
one member. The template returns a JSON array of full member records,
and the regen script cuts the list and detail responses out of it.
[<for(i, 1, getParam('members', 12))>
<if(getVar('i') > 1)>,<endIf>
{
"id": "usr_<appendBefore('0', getVar('i'), 4)>",
"name": <if(getVar('i') == 2)>
"<getRandomFullName()>-<getRandomSurname()> <getRandomSurname()>-<getRandomSurname()>"
<else>"<getRandomFullName()>"<endIf>,
"email": "<getRandomEmail('example.com')>",
"role": <if(getVar('i') == 1)>"owner"
<else>"<getRandomElement('admin', 'member', 'viewer')>"<endIf>,
"status": <if(getVar('i') == 3)>"invited"
<elseIf(getVar('i') == 4)>"suspended"<else>"active"<endIf>,
"city": "<getRandomCity()>",
"createdAt": "<getRandomDate('2025-01-01T00:00Z', '2026-06-30T23:59Z')>",
"lastActiveAt": <if(getVar('i') == 3)>null
<else>"<getRandomDate('2026-09-01T00:00Z', '2026-09-30T23:59Z')>"<endIf>
}<end_for>
]
Save it as cypress/fixtures/members.tmpl. What each part does:
- There are no variables. Every value is used once, so each one is
written inline in its field.
getParamsets the member count, defaulting to 12, directly in the loop bound. - Ids come from the loop index.
appendBeforepads it, so the members are alwaysusr_0001tousr_0012. Ids are deliberately not random and don't use sequences. Tests can nameusr_0003directly, and the same ids come back on every call. - The planted rows sit at fixed positions. Row 1 is the owner,
row 2 has a very long hyphenated name, row 3 is an invited member
who has never been active (
lastActiveAtisnull), and row 4 is suspended. Every other row is an ordinary active member. Each row carries one oddity, so a failing test points at one cause. - Dates come from fixed ranges.
getRandomDatereturns an ISO 8601 timestamp between the two bounds you give it. There's no "now" function. Relative text such as "Active 3 days ago" is the app's job, and making it stable is the test's job, withcy.clock(), covered below.
Template expressions support comparisons and &&/|| but no
arithmetic. That's why the planted rows are fixed index checks rather
than something like "every fifth row". Loop bounds are inclusive, and
the comma guard <if(getVar('i') > 1)>,<endIf> puts a comma before
every item except the first. The control flow docs
cover for and if/elseIf/else.
The name line is longer than the rest because a quoted value can't be
split across lines: a line break between the placeholders would land
inside the JSON string, and the output would no longer be valid JSON.
Rendered with seed 20261013 and the default parameters, the first
four records came out like this. The other eight are ordinary active
members:
[
{
"id": "usr_0001",
"name": "Janice Nicklaus",
"email": "[email protected]",
"role": "owner",
"status": "active",
"city": "Fuzhou",
"createdAt": "2025-05-16T21:46:59.991Z",
"lastActiveAt": "2026-09-01T12:29:03.612Z"
},
{
"id": "usr_0002",
"name": "Linda Norcross-Saxfield Cornridge-Thornstone",
"email": "[email protected]",
"role": "viewer",
"status": "active",
"city": "Glasgow",
"createdAt": "2026-04-19T14:13:46.680Z",
"lastActiveAt": "2026-09-27T20:29:12.354Z"
},
{
"id": "usr_0003",
"name": "Alan Holtwood",
"email": "[email protected]",
"role": "member",
"status": "invited",
"city": "Eugene",
"createdAt": "2025-08-05T05:03:30.210Z",
"lastActiveAt": null
},
{
"id": "usr_0004",
"name": "Delella Bradman",
"email": "[email protected]",
"role": "admin",
"status": "suspended",
"city": "Hartford",
"createdAt": "2025-05-16T03:00:05.532Z",
"lastActiveAt": "2026-09-06T16:32:57.623Z"
}
]
The long name is 44 characters, about three times the others. If your
name column is wider than that, add another -<getRandomSurname()>.
The email doesn't match the name, because each is drawn separately. For
a members table that doesn't matter. If your UI derives one from the
other, generate the email in the app's format instead.
The regen script: commit the files, not the API call
The regen script is the only code that calls JsonFabrica for the
stubbed tests. It's plain Node (20 or later, for the built-in fetch
and parseArgs). It sends the template to
POST /v1/templates/generate, which
renders a template without saving it, and writes the fixture files.
Cypress never runs it, and CI never calls the API for these tests:
they read the committed files.
#!/usr/bin/env node
// cypress/fixtures/regen.mjs
// Regenerates cypress/fixtures/members/ from members.tmpl. Run it by
// hand when the template or the API shape changes, then review and
// commit the output. Cypress never runs this; it reads the files.
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { parseArgs } from "node:util";
const API_URL = "https://api.jsonfabrica.com/v1/templates/generate";
const HERE = new URL("./", import.meta.url);
const OUT = new URL("members/", HERE);
const DEFAULT_SEED = 20261013; // change only to replace the data
const { values: args } = parseArgs({
options: {
seed: { type: "string", default: String(DEFAULT_SEED) },
members: { type: "string", default: "12" },
},
});
const seed = Number(args.seed);
const params = { members: Number(args.members) };
const response = await fetch(API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
body: await readFile(new URL("members.tmpl", HERE), "utf8"),
seed,
params,
}),
});
if (!response.ok) {
// {"error": {"code", "message", "details"}}, e.g. INVALID_TEMPLATE
console.error(response.status, await response.text());
process.exit(1);
}
const { data: members, meta } = await response.json();
if (meta.seed !== seed) {
console.error(`API used seed ${meta.seed}, expected ${seed}`);
process.exit(1);
}
// Start clean, so a smaller --members run leaves no stale files.
await rm(OUT, { recursive: true, force: true });
await mkdir(OUT);
const write = (name, value) =>
writeFile(new URL(name, OUT), JSON.stringify(value, null, 2) + "\n");
// The list endpoint returns a summary per member, the detail endpoint
// the full record. Both are cut from the same generated rows.
const summary = ({ id, name, email, role, status }) =>
({ id, name, email, role, status });
const items = members.map(summary);
await write("list.json", { items, total: items.length });
await write("list-empty.json", { items: [], total: 0 });
for (const member of members) await write(`${member.id}.json`, member);
await write("_meta.json", { seed: meta.seed, params });
console.log(`wrote ${members.length} members (seed ${meta.seed})`);
Run it with node cypress/fixtures/regen.mjs. It writes:
cypress/fixtures/
members.tmpl
regen.mjs
members/
_meta.json seed and params that produced these files
list.json GET /api/members
list-empty.json GET /api/members with no members
usr_0001.json GET /api/members/usr_0001
...
usr_0012.json
errors/
404.json written by hand, see below
500.json
The template and the script sit in the fixtures folder next to the files they produce. Cypress only reads what a test asks for, so they don't affect the run. The details that matter:
- The response is
{ data, meta }.datais the generated array, already parsed, andmeta.seedis the seed the API used.meta.generatedAtchanges on every call, so it's left out of the files to keep regeneration diff-free. - List and detail can't disagree.
list.jsonand everyusr_*.jsonare cut from the same rows in the same response, so the name in a list row is the name on that member's detail page. - The empty list is written, not generated. There's nothing to
generate in
{ "items": [], "total": 0 }. The script writes it so that its envelope stays next to the one inlist.jsonand changes with it. - The seed check catches a seed that didn't arrive. A mistyped
--seed abcbecomesNaN.JSON.stringifysends it asnull, which the API treats as seed 0.meta.seedcan never equalNaN, so the script stops before writing files nobody can rebuild. - Same template, seed and params give the same files. Running the
script twice produces no diff. Raising
--membersadds files and leaves the existing rows as they were. Editing the template shifts the random draws, so commit the regenerated files in the same pull request as the template change.
cy.intercept fixture data for list and detail pages
With the files in place, the stubbed spec registers two intercepts: one for the list and one route handler for every detail request.
// cypress/e2e/members.cy.js
const LIST = { method: "GET", pathname: "/api/members" };
const DETAIL = { method: "GET", pathname: /^\/api\/members\/usr_\d+$/ };
describe("members page, stubbed API", () => {
beforeEach(() => {
cy.intercept(LIST, { fixture: "members/list.json" }).as("list");
cy.intercept(DETAIL, (req) => {
const id = new URL(req.url).pathname.split("/").pop();
req.reply({ fixture: `members/${id}.json` });
}).as("detail");
});
it("renders one row per member", () => {
cy.fixture("members/list.json").then(({ items }) => {
cy.visit("/members");
cy.wait("@list");
cy.get("[data-cy=member-row]").should("have.length", items.length);
});
});
it("opens the detail page of the clicked row", () => {
cy.fixture("members/usr_0005.json").then((member) => {
cy.visit("/members");
cy.contains("[data-cy=member-row]", member.name).click();
cy.wait("@detail");
cy.get("h1").should("have.text", member.name);
cy.get("[data-cy=member-city]").should("have.text", member.city);
});
});
});
A few points about this spec:
pathnamematches the path without the query string, so/api/members?page=1still hits the list stub, while the detail regex only matchesusr_ids.- The detail handler serves whichever member was requested. It reads the id from the URL and replies with that member's file, so any row the app opens gets its own record. An id with no file has no fixture to serve, so the request won't quietly get made-up data.
- The assertions come from the fixture, not from literals. The
test reads
usr_0005.jsonand checks the heading against itsname. After a regeneration with a new seed, the test still passes without edits, because it never hard-coded "Nina Crawley".
{ fixture: ... } sends the file as the response body. When a test
needs a variant of a file, such as a list with one member, load it
and pass the changed object as body instead of creating another
file:
cy.fixture("members/list.json").then((list) => {
const items = list.items.slice(0, 1);
cy.intercept(LIST, { body: { items, total: 1 } }).as("list");
});
Planted states: empty list, long names, error responses
The planted rows and the extra files exist for tests like these. They
go in the same describe block, so the beforeEach stubs are already
registered. Since Cypress 7, a newer cy.intercept() for the same
request takes precedence over an older one, so a test can replace the
default list stub just by registering its own.
it("shows the empty state", () => {
cy.intercept(LIST, { fixture: "members/list-empty.json" }).as("empty");
cy.visit("/members");
cy.wait("@empty");
cy.contains("No members yet").should("be.visible");
});
it("keeps row actions reachable with a very long name", () => {
cy.fixture("members/usr_0002.json").then(({ name }) => {
cy.visit("/members");
cy.contains("[data-cy=member-row]", name).within(() => {
cy.get("[data-cy=row-actions]").should("be.visible");
cy.get("[data-cy=member-name]").should("have.attr", "title", name);
});
});
});
it("shows Never for an invited member", () => {
cy.visit("/members/usr_0003");
cy.wait("@detail");
cy.get("[data-cy=last-active]").should("have.text", "Never");
});
it("shows a retry banner when the list request fails", () => {
cy.intercept(LIST, { statusCode: 500, fixture: "errors/500.json" })
.as("failed");
cy.visit("/members");
cy.wait("@failed");
cy.get("[data-cy=error-banner]").should("contain", "Try again");
});
it("shows not found for a deleted member", () => {
cy.intercept(
{ method: "GET", pathname: "/api/members/usr_0404" },
{ statusCode: 404, fixture: "errors/404.json" },
);
cy.visit("/members/usr_0404");
cy.contains("Member not found").should("be.visible");
});
What each one catches:
- Empty list: a page that renders an empty table, a spinner that
never stops, or a crash on
items[0]. - Long name: a name cell that pushes the actions menu out of the
row. Cypress generally treats an element pushed outside an ancestor
that hides overflow as not visible, so the first assertion fails if
the layout breaks. The
titlecheck is one way of keeping the full name available; use whatever your design specifies. - Invited member:
new Date(null)is the Unix epoch, so a page that formatslastActiveAtwithout checking fornullshows a date in 1969 or 1970, or "57 years ago", instead of "Never". - 500 and 404: the error paths, which a happy-path fixture never reaches.
The two error bodies are written by hand, and should be. They're small, static, and their shape is whatever your API's real error responses look like. Copy one from a real response:
{ "error": { "code": "internal", "message": "Something went wrong" } }
Relative times need one more step. If the page shows "Active 17 days
ago", the text depends on today's date, and no fixture can pin that
down. Freeze the browser's clock with cy.clock() before cy.visit(),
and set it relative to the fixture's own timestamp:
const DAY = 24 * 60 * 60 * 1000;
it("shows how long ago a member was last active", () => {
cy.fixture("members/usr_0005.json").then(({ lastActiveAt }) => {
cy.clock(Date.parse(lastActiveAt) + 17 * DAY, ["Date"]);
cy.visit("/members/usr_0005");
cy.get("[data-cy=last-active]").should("contain", "17 days ago");
});
});
The frozen time is exactly 17 days after lastActiveAt, so the test
keeps passing after a regeneration with a new seed, like the others.
Passing ["Date"] freezes only Date. By default cy.clock() also
takes over setTimeout and setInterval, which stops debounced
inputs and polling until you call cy.tick().
These are page-level states, checked through the whole app in a browser. If you mainly want to see each state of a single component in isolation, Storybook stories are a cheaper place for them; see Storybook mock data.
Seed the database before Cypress tests with cy.task
Stubs can't catch a frontend and a backend that disagree, so some
tests should run against the real API with known rows in the
database. The template above can seed those too. A cy.task handler
in cypress.config.js calls the JsonFabrica API from Node and inserts
the result. This example uses PostgreSQL and the pg package:
// cypress.config.js
const { defineConfig } = require("cypress");
const { readFile } = require("node:fs/promises");
const path = require("node:path");
const { Client } = require("pg");
const API_URL = "https://api.jsonfabrica.com/v1/templates/generate";
const TEMPLATE = path.join(__dirname, "cypress/fixtures/members.tmpl");
// E2E_DATA_SEED=random lets the API pick a seed. Unset, the run uses
// the same seed as the committed fixtures.
const SEED = process.env.E2E_DATA_SEED ?? "20261013";
let generated; // one API call per run, shared by every spec
async function generateMembers() {
const request = {
body: await readFile(TEMPLATE, "utf8"),
params: { members: 12 },
};
if (SEED !== "random") request.seed = Number(SEED);
const response = await fetch(API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`JsonFabrica ${response.status}: ${detail}`);
}
const { data, meta } = await response.json();
console.log(`[db:seedMembers] test data seed ${meta.seed}`);
return { seed: meta.seed, members: data };
}
async function seedMembers() {
generated ??= generateMembers();
const { seed, members } = await generated;
const db = new Client({ connectionString: process.env.E2E_DATABASE_URL });
await db.connect();
try {
await db.query("TRUNCATE members CASCADE");
for (const m of members) {
await db.query(
`INSERT INTO members (id, name, email, role, status, city,
created_at, last_active_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[m.id, m.name, m.email, m.role, m.status, m.city,
m.createdAt, m.lastActiveAt],
);
}
} finally {
await db.end();
}
return { seed, members };
}
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
setupNodeEvents(on) {
on("task", { "db:seedMembers": seedMembers });
},
},
});
The spec calls the task before every test:
// cypress/e2e/members-db.cy.js
describe("members page, real backend", () => {
beforeEach(() => {
cy.task("db:seedMembers").then(({ seed, members }) => {
cy.log(`test data seed ${seed}`);
cy.wrap(members).as("members");
});
});
it("shows Never for an invited member", () => {
cy.visit("/members/usr_0003");
cy.get("[data-cy=last-active]").should("have.text", "Never");
});
it("keeps a suspension after a reload", () => {
cy.get("@members").then((members) => {
const { name } = members.find((m) => m.id === "usr_0005");
cy.visit("/members");
cy.contains("[data-cy=member-row]", name)
.find("[data-cy=suspend]")
.click();
cy.reload();
cy.contains("[data-cy=member-row]", name)
.should("contain", "Suspended");
});
});
});
Why it's built this way:
- Generation happens once per run, the reset before every test.
generated ??=caches the API call in the Node process that runssetupNodeEvents, so a suite with 40 tests makes one request. The truncate and inserts run on everycy.taskcall, which is what makes the suspension test safe to run before or after any other. - Test isolation doesn't reset your database. With
testIsolationon (the default since Cypress 12), Cypress clears the page, cookies and storage before each test. Rows a test wrote to your database are still there, which is why the reseed is inbeforeEachand notbefore. - The API key stays in Node. The handler reads
JSONFABRICA_API_KEYfromprocess.envin the config process. Nothing needs to be passed to spec code in the browser, where Cypress environment values can be read by the tests. - The task returns something. Cypress fails a task whose handler
returns
undefined, so a task with nothing to report should returnnull. This one returns the members and the seed, so tests can look up a planted row's name without hard-coding it. - Planted rows don't depend on the seed.
usr_0003is the invited member whatever the seed, because the template plants it by position. A random seed changes names, emails and dates, but not which row is which.
The task uses global fetch. Recent Cypress versions run the config
file with the Node.js installed on your machine, so that needs Node 18
or later. A task also has to finish within taskTimeout, 60 seconds by
default, which a single API call and a dozen inserts are well inside.
If CI shouldn't call the API at all, have the task read the committed
members/*.json files instead of fetching. You give up random seeds
but keep the same rows.
Reproduce a failure from the logged seed
A fixed seed makes the suite repeatable. A random seed now and then catches assertions that only pass because of one particular generated value, such as a sort test that passes because the first two names happen to be in order already. Run that variant on a schedule, not on every pull request:
E2E_DATA_SEED=random npx cypress run --spec cypress/e2e/members-db.cy.js
The seed the API picked shows up twice: in the terminal output, from
the console.log in the config, and in the Command Log through
cy.log, which means screenshots of failed tests show it too:
[db:seedMembers] test data seed 2891272531
To reproduce the failure, run the suite with that seed:
E2E_DATA_SEED=2891272531 npx cypress run --spec cypress/e2e/members-db.cy.js
The same template, seed and params give the same rows, so the database holds exactly what the failing run had. When the bug is in the UI rather than the backend, turn it into a stubbed regression test by writing the same data as fixture files:
node cypress/fixtures/regen.mjs --seed 2891272531
With seed 2891272531, usr_0003 came out as Diego Cunningham in
both the task's rows and the regenerated usr_0003.json. This only
holds if the template and the members param haven't changed since
the failing run, so record the commit as well as the seed.
Commit the regenerated fixtures only if you want this data to become
the new baseline. Otherwise, add the case as a planted row to the
template.
When a hand-written fixture or Faker is enough
A template is overkill for a lot of Cypress test data:
- Static reference lists. The options of a role dropdown, a list of countries or the plan tiers are product configuration. Write them by hand, or better, import them from the app's own constants so they can't drift.
- Error bodies and one-off responses. As above: a 500 body is one line and never varies.
- Values no assertion reads. A signup test that types an email
only needs a valid, unused one. A literal such as
[email protected]plus a cleanup, or Faker'sfaker.internet.email()inline in the spec, is enough. - A Faker script you already have.
faker.seed(n)makes Faker deterministic too. If a Node script with Faker already writes your fixtures and keeps list and detail in sync, keep it.
A generated template earns its place when ids have to line up across several files, when planted rows need to stay at known positions, and when the same data should feed both the stubbed specs and the seeded database. The same regen-and-commit pattern also works for unit tests that use JSON fixtures; see Jest test data.
FAQ
How do I use a JSON fixture in Cypress?
Put the file under cypress/fixtures, the default fixtures folder, and
load it with cy.fixture("members/list.json"), which yields the parsed
JSON. To serve the file as a network response instead, pass it to
cy.intercept as a static response, for example
{ fixture: "members/list.json" }. Paths are relative to the fixtures
folder, and the Cypress docs note that a fixture is loaded once, so
rewriting the file during a run doesn't change what the test sees.
How do I stub an API response with a fixture in cy.intercept?
Call cy.intercept with a route matcher and a static response that
names the fixture, such as
cy.intercept({ method: "GET", pathname: "/api/members" }, { fixture: "members/list.json" }).
Add statusCode to the same object to stub an error, for example
{ statusCode: 500, fixture: "errors/500.json" }. Register the
intercept before cy.visit or before the action that sends the
request, and give it an alias with .as() so the test can cy.wait
for it.
How do I seed the database before Cypress tests?
Register a task in setupNodeEvents in cypress.config.js that
empties the tables and inserts known rows, then call it with cy.task
in a beforeEach hook. The task runs in Node, so it can use a database
client and secrets that spec code in the browser shouldn't hold.
Cypress test isolation clears cookies, storage and the page between
tests, but not your database, so reseed before each test if tests
change data.
Should Cypress tests stub the API with cy.intercept or use a real backend?
Use both, for different jobs. Stubbing with cy.intercept and fixtures
is fast and makes UI states such as an empty list, a 500 error or a
very long name easy to reproduce, but it can't catch a frontend and a
backend that disagree. A smaller set of tests against a real, seeded
backend covers that, and generating both data sets from the same
template keeps them consistent.
Is there a JsonFabrica plugin for Cypress?
No. JsonFabrica is a JSON-over-HTTP API, and there is no Cypress
plugin, custom command or npm test package. You call the API from your
own code: a Node script that writes fixture files you commit, or a
cy.task handler in setupNodeEvents that fetches the data and
inserts it into your test database.
Seeded templates, planted rows and the { data, meta } response with
its seed are all part of the
templates API. Generate your Cypress
fixtures once, commit them, and keep the same template for the tests
that seed a real backend.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.