The one idea
Every other endpoint returns one thing — a PESEL, an email, an address. To seed a database
you would call dozens of them and stitch the pieces together yourself: draw a customer, draw
their orders, draw each order's items, then wire up the ids so they match. The
/v1/compose endpoint does all of that in a single call —
the whole nested dataset, foreign keys already lined
up.
You send a JSON skeleton in the exact shape your application expects — nest it as deep as your schema goes. Wherever a value should be generated, you write a short source string naming the generator. Everything else is treated as a literal and passed straight through. We return the same skeleton with real, valid values dropped into every source slot, ready to load.
You own the left side — the field names and the nesting. We own the right side — valid, checksum-correct, seed-reproducible values.
A first look
Send this to POST /v1/compose:
{
"shape": {
"id": "$generator.any.uuid.value",
"status": "active",
"customer": {
"firstName": "$generator.pl.person.name",
"email": "$generator.pl.email.value"
}
}
} …and you get back:
{
"data": {
"id": "9972daab-2c86-459f-9d78-3fe1be4e3280",
"status": "active",
"customer": {
"firstName": "Anna",
"email": "anna.nowak@wp.pl"
}
},
"meta": { "seed": 802673300, "generatorId": "compose" }
} "active" came back verbatim — a plain string is a literal.
The two $generator.… strings were replaced with generated
values.
We did not send a seed, so the values are random — run this
again and you get a different person. The seed the server
chose is always returned in meta, so you can pin it and
reproduce this exact record later. That is optional — see
Making test
data reproducible.
Sources vs. literals
The single rule that decides how a value is treated:
- A string starting with
$is a source — we resolve it and substitute the generated value. An unresolvable source is a validation error (a400), never a silent pass-through. - An object starting with a reserved key —
generator,alias,shared,date— is the same source written out longhand (see Object form), andconstforces a literal. - Anything else — a string without a leading
$, a number, a boolean, an array, an object that doesn't start with one of those five keys — is a literal, returned exactly as sent.
{
"id": "$generator.any.uuid.value", // source → generated
"state": "active", // literal → "active"
"visible": true, // literal → true
"colorIds": [5, 8, 12, 34] // literal → the array, unchanged
}
Need a literal string that does start with
$ — a price like "$5.00"? Wrap
it in const:
{ "price": { "const": "$5.00" } } // literal "$5.00", not a source const is the escape hatch for literals. It works for the other
reserved keys too: if you genuinely need a field
named const — or
date, or alias — it simply nests:
{ "const": { "const": "active" } }.
The four source prefixes
There are four kinds of source, and in string form each begins with its own
$ keyword. Every one of them can also be written as an object —
which you need for generator parameters — see
Object form.
| Source | Meaning |
|---|---|
$generator.<id>[.<field>] | Draw that generator fresh. $generator.pl.person → the whole object; $generator.pl.person.pesel → just that field. |
$alias.<name>[.<field>] | A local entity you declared in $generators (see below). Reuse it so several fields describe the same thing. |
$shared.<name>[.<field>] | A global entity declared in top-level shared. Drawn once for the whole request. |
$date.<when>.<format> | A computed date/time — see Dates. |
Picking one field
A generator returns an object. Add a dotted suffix to pull out a single field; omit it to keep the whole object.
"$generator.pl.person" → { "name": "Anna", "surname": "Nowak", "pesel": "…", … }
"$generator.pl.person.name" → "Anna"
"$generator.any.uuid" → { "value": "9972…", "version": "4" }
"$generator.any.uuid.value" → "9972daab-2c86-459f-…"
Generator ids never contain internal dots (pl.person,
ro.vehicle-registration,
any.uuid), so the id is always the first two segments and
anything after is the field you are picking. Picking a field that does not exist (for
example .name on any.uuid) is a
400 that lists the fields that generator actually returns.
Object form & parameters
Every source can also be written as an object. The
object is the real form; the "$…" strings are a shorthand for
when the whole thing fits comfortably on one line. These pairs mean exactly the same:
"$generator.pl.person.name" ≡ { "generator": "pl.person", "pick": "name" }
"$alias.buyer.pesel" ≡ { "alias": "buyer", "pick": "pesel" }
"$shared.admin.id" ≡ { "shared": "admin", "pick": "id" }
"$date.+5d+3h.full" ≡ { "date": "+5d+3h", "format": "full" }
"$date.+0d~+90d.day" ≡ { "date": "+0d", "to": "+90d", "format": "day" }
Each object starts with the key that names its kind —
generator, alias,
shared or date — and
pick selects one field where it applies. Note the range
~ belongs to the string shorthand only: as an object, a range
is simply date plus to.
Parameters need the object form — there is no string shorthand for them. Many generators take some: a PESEL for a female, a UUID v7, a Nano ID of a certain size.
{
"buyerPesel": {
"generator": "pl.pesel",
"params": { "sex": "f" }
},
"shortId": {
"generator": "any.nanoid",
"params": { "size": 8 },
"pick": "value"
}
} params are exactly the ones each generator accepts on its own
endpoint — the API
reference lists them — and may be omitted when you need none. If you need neither params
nor a pick, prefer the shorter string form
("$generator.pl.pesel").
One consequence: those five names —
generator, alias,
shared, date,
const — are reserved as the first key of an object.
If one of your own fields is called date, wrap the value in
const to keep it a plain literal.
Making lists — $count
By default a node is a single object. Add $count to a node and
it becomes an array of that many.
{
"shape": {
"$count": 100, // → an array of 100 records
"id": "$generator.any.uuid.value",
"name": "$generator.pl.person.name"
}
} "$count": 50→ exactly 50."$count": [20, 60]→ a random count between 20 and 60 (inclusive), each time."$count": 0→ an empty array.
Nested counts multiply naturally: 100 orders each holding 2–5 items gives 100 orders, and each order rolls its own item count.
A range count is random, so it is not identical
between runs — that is the nature of the data, not the seed. When you need a byte-for-byte
reproducible batch, use a fixed number and a fixed seed.
Keeping fields consistent
This is the part that matters most, so read it carefully. Two separate sources are two separate draws:
{
"firstName": "$generator.pl.person.name", // one person
"pesel": "$generator.pl.person.pesel" // a DIFFERENT person
}
The name and the PESEL above belong to different
people — each $generator.… draws afresh. To make
several fields describe the same person, declare that
person once as a local entity in $generators, then reference it
with $alias:
{
"shape": {
"$generators": {
"buyer": { "generator": "pl.person", "params": { "sex": "f" } }
},
"firstName": "$alias.buyer.name",
"lastName": "$alias.buyer.surname",
"pesel": "$alias.buyer.pesel" // same person as firstName
}
} buyer is drawn once; every $alias.buyer.…
reads a field from that one person, so name, surname, and PESEL agree (sex and birth date
encoded in the PESEL match the name).
Local vs. global — $generators vs. shared
Both declare a reusable entity; the difference is scope and how often it is drawn.
-
$generators(on any node) — a local constant. Visible only inside that node's subtree, and drawn fresh for each repetition of it. Use it for “each order has its own buyer.” -
shared(top level) — a global constant. Drawn once per request; every reference across every record is the same entity. Use it for “one admin created all of these rows.”
Dates — $date
For fields like createdAt, compute a date relative to a fixed
anchor. Declare the anchor and your output formats once, at the top level:
{
"time": {
"start": "2026-01-01T00:00:00Z",
"formats": {
"day": "yyyy-MM-dd",
"full": "yyyy-MM-dd'T'HH:mm:ssXXX"
}
}
}
You name the formats yourself and write them with
date-fns format tokens.
Format names may not contain a dot — . separates the segments
of every source. Then a $date source applies signed offsets to
time.start and renders with one of your named formats:
"createdAt": "$date.+0d.full" → 2026-01-01T00:00:00+00:00
"shipBy": "$date.+3mo+5d.day" → 2026-04-06
"absolute": "$date.2026-06-15.day" → 2026-06-15
A $date source is always three parts:
$date, when, and the format name. Several offsets are written
joined together — +3mo+5d, not
+3mo.+5d. The offset units:
y | years |
mo | months |
w | weeks |
d | days |
h | hours |
mi | minutes |
s | seconds |
Offsets are signed (-3mo, +2y) and
summed; the last part is always the format name. Provide time.start
whenever you use $date — without it the anchor falls back to the
server's current time, and those fields will differ between runs.
Date ranges — ~
A fixed offset gives all 100 records the same timestamp. Put a
~ between two bounds and each record draws its own date,
uniformly between them:
"createdAt": "$date.+0d~+90d.day" → a different day per record
"createdAt": "$date.~+90d.day" → same thing; empty start = the anchor
"window": "$date.+0d+3h~+90d-4h.full" → both bounds can be full expressions
"born": "$date.1990-01-01~2005-12-31.day" -
Leave the start empty (
~+90d) and the anchor is used. Leaving the end empty is an error — there is no natural end. -
-always means minus, never a range:+90d-4his 90 days minus 4 hours. Only~makes a range. -
Values land on the smallest unit you mention —
+0d~+90dgives whole days, so a timestamp format shows midnights. Want times too? Ask in hours:+0h~+2160h. - A backwards range (
+90d~+0d) is rejected — it's nearly always a typo. -
Ranges are random, so a response with them repeats byte-for-byte only when you pass a
seed.
Dates that depend on other dates
An order should be created after its customer, and shipped after it was
ordered. On their own, all offsets measure from time.start, so
they can't express that. Add $anchor to a block and every
date inside it measures from the field you name instead:
{
"createdAt": "$date.-2y~+0d.full",
"orders": {
"$count": [2, 5],
"$anchor": "$parent.createdAt", ← measure from this order's customer
"createdAt": "$date.+0d~+6mo.full" → always after that customer
}
}
You can point at the current record ($this.createdAt), the
block that contains it ($parent.createdAt), or further up with
a digit — $parent2.createdAt is two levels up,
$parent3 three. Inside a repeated block, these always mean
that exact item: each order anchors on its own
customer, not on some shared one.
One date that shouldn't follow the others? A birth
date sits before createdAt, so measuring it from the
anchor would be wrong. Write that field in object form with
"after": null and it goes back to measuring from
time.start:
"$anchor": "$parent.createdAt",
"shippedAt": "$date.+1d~+14d.full", → after the parent
"birthDate": { "date": "-60y", "to": "-18y",
"format": "day", "after": null } → from time.start
You can also use after on its own, without any
$anchor, to anchor a single field. And note RFD does not force
dates to be in order — +0d~+6mo lands after the anchor because
the offsets are positive; negative offsets go backwards, which is exactly how
-60y~-18y gives you an adult's birthday.
Making test data reproducible
Everything above works without a seed. Leave it out and every
call returns fresh random data — which is what you usually want for realistic-looking
fixtures. But when you need the same data every time — a
stable demo, a snapshot test, a bug you want to reproduce — a seed makes the output exactly
repeatable.
The rule: no seed → random (different each call).
A seed → identical output for that seed, forever. Either way, the seed actually used is
returned in meta.seed, so you can grab it from a run you liked
and pin it afterwards.
One record
Put seed at the top of the request. The same seed reproduces
the same single record, field for field.
{
"seed": 42,
"shape": {
"id": "$generator.any.uuid.value",
"buyer": "$generator.pl.person.name"
}
}
// → always the same uuid and the same name, every call A set of records
When a node has $count, the top-level seed still makes the whole
batch reproducible. Under the hood the records are drawn in order from that one seed, so
each record differs from the next, yet the entire array is identical run to run. Record 1 is
also exactly what you would have gotten from the same seed with no $count.
{
"seed": 42,
"shape": {
"$count": 3,
"id": "$generator.any.uuid.value"
}
}
// → the SAME three uuids, in the same order, every call:
// ["9972daab-…", "af9c0078-…", "7ccf5173-…"]
One caveat, mentioned earlier: a random count
("$count": [20, 60]) is by definition not fixed, so a batch
sized by a range is not byte-for-byte repeatable. Use a fixed number when you need an exact
repeat.
Where the seed can live
There are two places a seed can go, and you can use either or both.
1. One seed at the top (the usual case). It governs the entire request — every generator, every record, every field flows from it. This is all you need to make a whole response reproducible.
{
"seed": 42,
"shape": {
"$count": 100,
"name": "$generator.pl.person.name",
"email": "$generator.pl.email.value"
}
}
// the top seed fixes all 100 records 2. A seed deep inside a single generator's
params. Every generator also accepts its own
seed as a parameter. Pin it on one field and that field becomes
a fixed constant — the same value on every record — while
everything around it stays driven by the top seed (or stays random, if there is no top
seed).
{
"shape": {
"$count": 100,
"region": {
"generator": "pl.address",
"params": { "seed": 7 }, // this field is pinned…
"pick": "voivodeshipName"
},
"buyer": "$generator.pl.person.name" // …this one still varies per record
}
}
// every order gets the SAME region, but a different buyer 3. Both together. A top-level seed makes the whole
set reproducible, and a per-generator params.seed pins specific
fields to fixed values within it. The top seed gives you “the same batch every run”; the
inner seed gives you “this particular field is always this exact value.” They compose — the
inner seed simply overrides the top seed for the one field it sits on.
Rule of thumb: top-level seed = “reproduce the whole response.” params seed = “freeze this one field to a known value.” Reach for the top one first; use the inner one only when a specific field must be a constant.
Everything together
100 orders. Each has a consistent buyer, a created-at date, and its own 2–5 stock items. Every order was created by the one same admin.
{
"time": {
"start": "2026-01-01T00:00:00Z",
"formats": { "full": "yyyy-MM-dd'T'HH:mm:ssXXX" }
},
"shared": {
"admin": { "generator": "pl.person", "params": { "sex": "f" } }
},
"shape": {
"$count": 100,
"$generators": {
"buyer": { "generator": "pl.person", "params": { "sex": "f" } }
},
"id": "$generator.any.uuid.value",
"status": "active",
"createdBy": "$shared.admin.pesel", // SAME admin on every order
"createdAt": "$date.~+90d.full",
"buyer": {
"firstName": "$alias.buyer.name", // ┐ same buyer,
"lastName": "$alias.buyer.surname", // │ fresh per order
"pesel": "$alias.buyer.pesel", // ┘
"birthDate": { "date": "-60y", "to": "-18y",
"format": "day", "after": null } // from time.start, not the order
},
"items": {
"$count": [2, 5], // each order: its own 2–5
"$anchor": "$parent.createdAt", // each item measured from ITS order
"shippedAt": "$date.+1d~+14d.full", // 1–14 days after that order
"sku": "$generator.any.nanoid.value",
"price": { "const": "$0.00" } // literal starting with "$"
}
}
}
Returns an array of 100 orders. Every createdBy is the one
shared admin; each order's buyer fields agree with one another;
each items array is independently 2–5 long, and every
shippedAt falls after the date of the order it belongs to —
while birthDate, opted out with
after: null, stays an ordinary adult birthday.
Todo app
The smallest real shape: a user who owns a list of tasks. One request seeds the whole thing — the user, and a nested array of tasks each with its own id, title, done flag, and due date.
{
"seed": 7,
"time": { "start": "2026-03-01T00:00:00Z", "formats": { "day": "yyyy-MM-dd" } },
"shape": {
"userId": "$generator.any.uuid.value",
"userName": "$generator.any.person-name.name",
"tasks": {
"$count": 3,
"id": "$generator.any.nanoid.value",
"title": { "generator": "any.lorem", "params": { "words": 4 }, "pick": "value" },
"done": { "generator": "any.enum", "params": { "choices": "{\"false\":2,\"true\":1}" }, "pick": "value" },
"due": "$date.+1d~+30d.day"
}
}
} You get a user with three tasks:
{
"userId": "020ffab2-8567-473d-8dba-4227c384325e",
"userName": "Salme",
"tasks": [
{ "id": "126PDfnyDW_iWykeTxKsd", "title": "Lorem ipsum dolor sit.", "done": "false", "due": "2026-03-22" },
{ "id": "_5x2-y71O5e_yDkLymNLv", "title": "Ut labore et dolore.", "done": "true", "due": "2026-03-17" },
{ "id": "kuI5ZDfcBkKObD73usrlW", "title": "Magna aliqua enim ad.", "done": "false", "due": "2026-03-10" }
]
}
What each piece does: $count: 3 turns
tasks into an array; any.lorem
with words: 4 keeps titles short; any.enum
weights the done flag two-to-one toward
false; and $date.+1d~+30d spreads
due dates across the coming month. The seed makes the whole set
reproducible — run it again for the same three tasks.
Online shop
Orders with real products and prices. This is where any.offering
earns its place — it draws a genuine product or service with a plausible price — and where
shared keeps one store across every order.
{
"seed": 3,
"time": { "start": "2026-01-01T00:00:00Z", "formats": { "day": "yyyy-MM-dd" } },
"shared": { "store": { "generator": "any.company-name" } },
"shape": {
"$count": 2,
"orderId": "$generator.any.uuid.value",
"store": "$shared.store.value",
"placedAt": "$date.-6mo~+0d.day",
"customer": { "generator": "any.person-name", "pick": "name" },
"items": {
"$count": [1, 2],
"product": "$generator.any.offering.offeringName",
"price": "$generator.any.offering.price",
"currency": "$generator.any.offering.currency"
}
}
} Two orders, same store:
[
{
"orderId": "7b7c2943-8d31-45b6-bacb-e152b049ac86",
"store": "Bluworks plc",
"placedAt": "2025-12-01",
"customer": "Molly",
"items": [
{ "product": "Návrh orientačního systému", "price": 7000, "currency": "EUR" },
{ "product": "Zeezoutspray", "price": 650, "currency": "EUR" }
]
},
{
"orderId": "b377fae1-b6ad-43c3-8e10-89dd588a4a76",
"store": "Bluworks plc",
"placedAt": "2025-10-11",
"customer": "Edoardo",
"items": [
{ "product": "Kaartje improvisatievoorstelling", "price": 12000, "currency": "EUR" },
{ "product": "Varovanje skladišča", "price": 26999, "currency": "EUR" }
]
}
]
Two things worth noting. The store is the same in both
orders — a shared entity is drawn once for the whole
request, so $shared.store resolves to one company everywhere.
(A shared entity is referenced with
$shared.<name>, not
$alias.<name> — $alias is for
the local $generators you declare on a node.) And
prices are in the currency's minor unit — cents —
so 7000 is €70.00 and 26999 is
€269.99. The products come back localised (Czech, Dutch, Slovenian here) because
any.offering draws across all 27 EU countries; pin
params.countries to narrow it.
Quick reference
seed | Optional. Omit → random data (differs each call). Set it → reproducible. Also settable per-generator via params.seed. See Making test data reproducible. |
shape | Required. The root object. Its field names and nesting are yours. |
shared | Optional. Global entities drawn once per request, referenced by $shared.<name>. |
time | Optional. start (ISO 8601 anchor) + named date-fns formats. Needed for $date. |
$count | On any node. N or [min, max] → an array. Absent → a single object. |
$generators | On any node. Local entities, referenced by $alias.<name>, drawn fresh per repetition. |
$anchor | On any node. Makes every $date inside measure from a resolved date — $this.<field>, $parent.<field>, $parent2.<field>… See Dates. |
const | Force a literal — for a literal string that starts with $. |
meta | In the response: the resolved seed (replay it for the identical result). |