Compose

Seed a database

Seed your whole database in one request — the entire nested shape your app expects, with foreign keys that line up, sensible dates, and a valid value in every field. Describe the shape once; we fill it and hand it back ready to load.

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 (a 400), never a silent pass-through.
  • An object starting with a reserved keygenerator, alias, shared, date — is the same source written out longhand (see Object form), and const forces 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:

yyears
momonths
wweeks
ddays
hhours
miminutes
sseconds

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-4h is 90 days minus 4 hours. Only ~ makes a range.
  • Values land on the smallest unit you mention+0d~+90d gives 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.

Social network

Users with posts, where every post is dated after the user joined. This is the $anchor feature: the posts measure their dates from their own user's joinedAt, not from a global start.

{
  "seed": 11,
  "time": { "start": "2026-06-01T00:00:00Z", "formats": { "full": "yyyy-MM-dd'T'HH:mm:ssXXX" } },
  "shape": {
    "$count": 2,
    "$generators": { "author": { "generator": "any.person-name" } },
    "userId":   "$generator.any.uuid.value",
    "handle":   "$alias.author.name",
    "joinedAt": "$date.-2y~-1mo.full",
    "posts": {
      "$count": [1, 2],
      "$anchor": "$parent.joinedAt",
      "body":     { "generator": "any.lorem", "params": { "words": 8 }, "pick": "value" },
      "postedAt": "$date.+1d~+300d.full"
    }
  }
}

Each user's posts fall after they joined:

[
  {
    "userId": "8914879b-737a-476b-abae-292d46656095",
    "handle": "Réka",
    "joinedAt": "2024-08-29T02:00:00+02:00",
    "posts": [
      { "body": "Lorem ipsum dolor sit amet consectetur adipiscing elit.", "postedAt": "2024-09-15T02:00:00+02:00" },
      { "body": "Lorem ipsum dolor, sit amet consectetur adipiscing elit.", "postedAt": "2025-05-10T02:00:00+02:00" }
    ]
  },
  {
    "userId": "13d77f86-4580-4b16-a40b-71a2fb6d775a",
    "handle": "Σταυρούλα",
    "joinedAt": "2024-07-04T02:00:00+02:00",
    "posts": [
      { "body": "Lorem ipsum dolor sit amet consectetur, adipiscing, elit.", "postedAt": "2025-01-10T01:00:00+01:00" }
    ]
  }
]

$anchor: "$parent.joinedAt" on the posts node rebinds where its $date fields measure from — so postedAt: "$date.+1d~+300d" means "1 to 300 days after this user joined", and every post lands after its own author's join date. $generators.author declared on the record keeps handle tied to one drawn person, fresh for each user. The handles come back in different scripts (Hungarian, Greek) because any.person-name spans locales.

Quick reference

seedOptional. Omit → random data (differs each call). Set it → reproducible. Also settable per-generator via params.seed. See Making test data reproducible.
shapeRequired. The root object. Its field names and nesting are yours.
sharedOptional. Global entities drawn once per request, referenced by $shared.<name>.
timeOptional. start (ISO 8601 anchor) + named date-fns formats. Needed for $date.
$countOn any node. N or [min, max] → an array. Absent → a single object.
$generatorsOn any node. Local entities, referenced by $alias.<name>, drawn fresh per repetition.
$anchorOn any node. Makes every $date inside measure from a resolved date — $this.<field>, $parent.<field>, $parent2.<field>… See Dates.
constForce a literal — for a literal string that starts with $.
metaIn the response: the resolved seed (replay it for the identical result).

This documents the request format for the live POST /v1/compose endpoint (Pro plan). Fields not marked with a $ source pass through verbatim.