Docs

Form data schema

How a form's fields become the keys and values of an entry's data, and how to get a machine-readable schema of your form.

Every entry stores what people filled in under data. The form decides its shape: which keys exist, where they nest, which values each key takes and when a key is required. This page shows how to read that shape from the form, which value each kind of field stores, and how to generate a schema of your form that your code can check against.

To read a form's definition, see Forms.

From form to data

formSchema.rootElem in GET /form/{formId} is a tree of elements. Every element has a type and a name. The first letter of the type tells you what it is:

  • c… is a container that groups other elements, such as a section (cSection), a repeating group (cRepeat), a row of columns (cRow) or tabs (cTabs). Its elements are in children.
  • f… is a field that holds a value, such as a text input (fInput), a date (fDate) or a file upload (fFile).
  • e… is content, such as text, a button or a calculation. It stores nothing in data.

The form definition leaves out every property that has its default value. A section without secFlat, for example, is a flat section.

Data paths

Each field's value sits at a path built from the name of the field and of some of the containers around it. The same paths apply when you prefill a form:

  • A field adds its name to the path.
  • A section (cSection) adds nothing, unless it has "secFlat": false. Then it adds its name, and its fields sit in a nested object.
  • A repeating group (cRepeat) adds its name and holds an array, one object per row. It takes at most 10 rows unless it sets repMaxRows. A row with no values is stored as null.
  • Rows (cRow) and tabs (cTabs) add nothing to the path of the fields inside them.
  • Tabs keep their own state under their name: { "__activeTab": 1 } is the tab the person had open. A collapsible section ("secCollapse": true) can keep { "__isCollapsed": true } under its own name in the same way, even when it's flat. Keys that start with __ are the form's screen state, not data. Ignore them when you read an entry.

Take this form, shown with only the properties that matter for paths:

{
  "type": "cSection",
  "name": "root",
  "children": [
    { "type": "fInput", "name": "fullName" },
    { "type": "cSection", "name": "address", "secFlat": false, "children": [
      { "type": "fInput", "name": "city" },
      { "type": "fInput", "name": "street" }
    ] },
    { "type": "cTabs", "name": "steps", "tabsStyle": "wizard", "children": [
      [ { "type": "cRepeat", "name": "references", "children": [
        { "type": "fInput", "name": "name" },
        { "type": "fInput", "name": "phone" }
      ] } ],
      [ { "type": "fFile", "name": "cv" }, { "type": "fSign", "name": "signature" } ]
    ] }
  ]
}

A submitted entry of this form holds:

{
  "fullName": "Dana Levi",
  "address": {
    "city": "Haifa",
    "street": "Herzl 10"
  },
  "steps": { "__activeTab": 1 },
  "references": [
    { "name": "Yossi Cohen", "phone": "050-0000000" },
    { "name": "Rina Katz", "phone": "052-0000000" }
  ],
  "cv": [
    {
      "name": "cv.png",
      "size": 70,
      "type": "image/png",
      "s3Key": "form/<formId>/<uuid>/cv.png",
      "s3Bucket": "<bucket>",
      "url": "https://<bucket>.s3.eu-west-1.amazonaws.com/form/<formId>/<uuid>/cv.png?<presigned-query>"
    }
  ]
}
  • address is an object, because its section has "secFlat": false.
  • references is an array, one object per row.
  • The tabs add nothing to the paths of references and cv. Their own key, steps, holds only the open tab.
  • signature is missing because nobody signed. A field with no value is absent from data.

The form builder shows the same paths. In the builder, press Alt+Shift+D to show each field's data path and data type under it.

Field types

Send each value in the shape the field stores, and expect the same shape when you read an entry. Every field type the form builder offers:

FieldTypeValueExampleWhen the field is empty
TextfInputA string. Other values are stored as text: 42 becomes "42"."Dana Levi"Key absent. A value of only whitespace counts as empty.
NumberfInput with "typeValidator": "num"A number. A numeric string such as "7" is stored as 7.4Key absent.
Long textfLngTxtA string. Line breaks are kept."Fast delivery.\nThanks!"Key absent.
Rich textfDocAn HTML string."<p>Hello</p>"Key absent.
SelectfSelectThe option's value, not its label. See Options."dev"Key absent.
Multi-selectfSelect with "selectMulti": trueAn array of option values. The form definition shows such a field with "typeValidator": "array".["he", "en"]Key absent. An empty array counts as empty.
Radio buttonsfRadioThe chosen option's value."email"Key absent.
CheckboxesfCheckboxAn array of the checked options' values. A single string is rejected.["morning"]Key absent.
Yes/nofBooltrue when checked. The API also takes "yes", "true", "on", "1" and 1 as true, and "no", "false", "off", "0" and 0 as false.trueKey absent. A field with "boolUncheckedValue": false stores false when someone leaves it unchecked in the form. Through the API, send false yourself.
DatefDateAn ISO 8601 date in UTC. A date is stored as midnight UTC of that day. Send YYYY-MM-DD, or a full timestamp ending in Z. Time zone offsets other than UTC are rejected."2026-10-01T00:00:00.000Z"Key absent.
Date and timefDate with "typeValidator": { "type": "date", "mode": "dt" }A full UTC timestamp."2026-10-01T09:30:00.000Z"Key absent.
TimefDate with "typeValidator": { "type": "date", "mode": "t" }A UTC timestamp. The form stores a time as a timestamp on January 1, 1970: send one in that shape. A full timestamp you send keeps its own date."1970-01-01T09:30:00.000Z"Key absent.
FilefFileAn array of file objects. Upload the file first. See Files and PDFs.[{ "name": "cv.png", … }]Key absent.
SignaturefSignAn opaque string in Tepez's own format. To prefill one, copy the value from an existing entry, or send the strokes as { "strokes": [{ "cWidth": 300, "cHeight": 100, "points": [{ "x": 10, "y": 50, "time": 0 }, …] }] }, which is stored in the same format."{\"x\":\"a1O2u\",…,\"cW\":300,\"cH\":100}"Key absent.
RatingfRateA number. The form offers whole stars from 1 to the field's rateMax (5 by default). The API doesn't check the range.4Key absent.
ColorfColorA color as text. The form writes it in the field's colorPreferredFormat, hex such as #ff0000 by default. The API also takes other color text, such as red or rgb(255, 0, 0), and stores it as sent."#ff0000"Key absent.
MatrixfMatrixAn object with one key per question (the values of matrixQuestions), each holding an option value. Keys that aren't questions are dropped.{ "speed": "3", "quality": "2" }Key absent. A question with no answer is absent from the object.

A value of the wrong type, such as a date with an offset or a string sent to a checkboxes field, fails with 400 entry_failed_validation and the message Invalid data type., on a draft save as well as on a submit.

A text input (fInput), radio buttons (fRadio) and a select (fSelect) can set another data type with typeValidator: "num", "date", "bool", "color" or "sign". Then the value takes the shape of that type in the table, as the number field above does.

Content elements (e…) store nothing, and that includes calculations (eEq). Some forms fill values in the browser, for example a total calculated from other fields or a default value. The API doesn't run those calculations. When you create an entry through the API, send every value you need, including the ones the browser would calculate.

Options

A select (fSelect), radio buttons (fRadio), checkboxes (fCheckbox) and a matrix (fMatrix) take their values from options. The form definition holds them in one of three ways.

A list of options. Each option is a pair of value and label, [value, label]. The value is what data stores and what you send. The label is what people see. In this field, the value to send is "dev", not "Developer":

{
  "type": "fSelect",
  "name": "position",
  "label": "Position",
  "options": [["dev", "Developer"], ["qa", "QA engineer"]]
}

An option given as a single value, such as "morning", uses the same text as value and label. Options can also be grouped: { "label": "Classic", "options": [["red", "Red card"], …] }. A group only changes how the list looks. Its options' values are stored the same way.

An expression. options is a string, an expression the form evaluates for each entry, such as "model.items.length > 3 ? [['l', 'Large']] : [['s', 'Small'], ['l', 'Large']]". It returns a list in the format above. The allowed values depend on the entry's other values.

An options set. options is an object with the url of an options set's autocomplete endpoint. The field offers the set's options as the person types. See Options sets.

When an entry is submitted, the API checks the value of a field with a list of options, or with an expression, against the options, and fails with not in options when it isn't one of them. A field whose options come from an options set accepts any value, unless the field also has a remote validator that checks the value against the set.

To read a field's allowed values, read the form and take options from the field. The script in Get a schema for your form lists them for every field.

Required and conditional fields

A field is required when it has reqValidator. The form definition shows it in one of three forms:

reqValidatorRequired
MissingNever.
trueAlways.
An object, such as { "req": "model.rating < 3", "message": "Leave an email so we can follow up." }When req is true or its expression is true for the entry. A missing req means always. message replaces the default error message.

An object with "level": "warning", "info" or "success" only shows its message in the form. It doesn't block a submit.

A required field fails when its value is empty, as defined in Field types. A matrix also fails when any of its questions has no answer. A required yes/no field accepts false, so it doesn't make the person agree to anything: check for true on your side. A required field inside a repeating group is required in every row, and the error names the row, for example model['items'][0]['product'].

A field, section or tab can also have relevant: false, or an expression such as "model.contactBy === 'phone'". While it's false, the form hides the element. When the entry is saved, every value inside it is dropped from data, and its required rule doesn't apply.

What this means when you write an entry through the API:

  • Submitting checks every rule of the form. A missing required value, or a value that isn't one of the options, fails with 400 entry_failed_validation. Saving a draft checks only the value types, unless the form sets formSchema.validateSaved.
  • A value you send for a hidden field is dropped. The request succeeds with 200, and the key is missing from the stored entry.
  • A key the form doesn't declare is dropped in the same way.

This form asks for a phone number only when the person wants to be called. The request sends a phone number, but contactBy is email, and also sends an employeeNumber the form doesn't have:

curl -X POST https://api.tepez.co.il/v1.0/entry/submit \
  -H "x-api-key: $TEPEZ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "form": "<formId>",
    "data": {
      "fullName": "Dana Levi",
      "position": "dev",
      "agreeToTerms": true,
      "contactBy": "email",
      "phone": "050-0000000",
      "employeeNumber": "12345"
    }
  }'

The request succeeds, and the stored entry has neither phone nor employeeNumber. The response, trimmed to the entry's data:

{
  "entry": {
    "id": "<entryId>",
    "status": "submitted",
    "data": {
      "fullName": "Dana Levi",
      "position": "dev",
      "contactBy": "email",
      "agreeToTerms": true
    }
  }
}

With "contactBy": "phone" and no phone, the same request fails:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "The entry failed validation on the server. Sorry, this is probably a BUG on our side. If the issue persists please contact us at support@tepez.co.il",
  "id": "entry_failed_validation",
  "messages": [
    {
      "modelPath": "model['phone']",
      "message": "You can't leave this empty.",
      "type": "error",
      "value": null
    }
  ]
}

Read the entry back and check that every value you sent is there.

Adding a required field breaks callers that don't send it

If someone adds a required field to a form, or makes an existing field required, every API call that submits the form without that field starts failing with 400 entry_failed_validation. Agree with whoever edits the form in the form builder that a new required field is a change to your integration, and deploy both together.

Get a schema for your form

In the form builder

The form builder's ⋮ menu, at the top of the builder, downloads two files that describe the form:

  • Fields report (דוח שדות): an Excel file. Its Fields sheet has a row for every field, with its display name, its path (such as model.address.city), its data type, where its options come from and whether it's required. A repeating group's fields appear once for each row it can hold, references[0], references[1] and so on. The Options sheet lists the label and value of every option. Other sheets list the PDF fields, the expressions and the texts of the form.
  • TypeScript types definition file (קובץ TypeScript types definition): a .d.ts file with one TypeScript interface for the data of the form and one for each nested section and repeating group. A field with a list of options is typed as the union of its option values, with their labels in a comment.

Both files describe the form as it is in the builder when you download them. Two things in the TypeScript file differ from the JSON you read: a date is typed Date, while data holds an ISO string, and every key is typed as present, while a field with no value is absent.

From the API

This script reads a form and prints a JSON array that describes every field: its data path, type, data type, whether it's required, the conditions that hide it and its options. It follows the rules in Data paths and Options:

const res = await fetch('https://api.tepez.co.il/v1.0/form/<formId>', {
  headers: { 'x-api-key': process.env.TEPEZ_API_KEY },
});
const form = await res.json();

// The data type of each field type that doesn't set its own typeValidator
const defaultDataTypes = {
  fCheckbox: 'array', fBool: 'bool', fDate: 'date', fFile: 'file',
  fSign: 'sign', fRate: 'num', fColor: 'color', fMatrix: 'matrix',
};

// Static options become { value, label }; other sources are described, not listed
function toOptions(options) {
  if (typeof options === 'string') return { expression: options };
  if (!Array.isArray(options)) return { remote: options.url };
  return options.flatMap((opt) => {
    if (opt.options) return toOptions(opt.options).map((o) => ({ ...o, group: opt.label }));
    const [value, label = value] = Array.isArray(opt) ? opt : [opt];
    return [{ value, label }];
  });
}

// Walk the element tree and describe every field
function describe(elem, prefix = '', conditions = []) {
  const { type, name } = elem;
  if ('relevant' in elem) conditions = [...conditions, elem.relevant];
  if (type.startsWith('f')) {
    const tv = elem.typeValidator;
    const req = elem.reqValidator;
    const field = {
      path: `${prefix}${name}`,
      type,
      dataType: (typeof tv === 'string' ? tv : tv?.type) ?? defaultDataTypes[type] ?? 'str',
      required: typeof req === 'object' ? (req.req ?? true) : (req ?? false),
    };
    if (conditions.length) field.relevant = conditions;
    if (elem.options) field.options = toOptions(elem.options);
    if (elem.matrixQuestions) field.questions = elem.matrixQuestions.map((q) => [q].flat()[0]);
    return [field];
  }
  if (!type.startsWith('c')) return []; // content elements (e*) hold no data
  let childPrefix = prefix;
  if (type === 'cSection' && elem.secFlat === false) childPrefix = `${prefix}${name}.`;
  if (type === 'cRepeat') childPrefix = `${prefix}${name}[].`;
  // cRow and cTabs hold one array of children per column or tab
  return (elem.children ?? []).flatMap((child, i) => {
    const tab = type === 'cTabs' ? elem.tabs?.[i] : undefined;
    const childConditions = tab && 'relevant' in tab ? [...conditions, tab.relevant] : conditions;
    return [child].flat().flatMap((c) => describe(c, childPrefix, childConditions));
  });
}

console.log(JSON.stringify(describe(form.formSchema.rootElem), null, 2));

For a job application form, it prints (trimmed to some of the fields):

[
  {
    "path": "fullName",
    "type": "fInput",
    "dataType": "str",
    "required": true
  },
  {
    "path": "yearsOfExperience",
    "type": "fInput",
    "dataType": "num",
    "required": false
  },
  {
    "path": "position",
    "type": "fSelect",
    "dataType": "str",
    "required": true,
    "options": [
      { "value": "dev", "label": "Developer" },
      { "value": "qa", "label": "QA engineer" }
    ]
  },
  {
    "path": "languages",
    "type": "fSelect",
    "dataType": "array",
    "required": false,
    "options": [
      { "value": "he", "label": "Hebrew" },
      { "value": "en", "label": "English" }
    ]
  },
  {
    "path": "phone",
    "type": "fInput",
    "dataType": "str",
    "required": true,
    "relevant": ["model.contactBy === 'phone'"]
  },
  {
    "path": "address.city",
    "type": "fInput",
    "dataType": "str",
    "required": false
  },
  {
    "path": "references[].name",
    "type": "fInput",
    "dataType": "str",
    "required": false
  },
  {
    "path": "cv",
    "type": "fFile",
    "dataType": "file",
    "required": false
  }
]

For an order form, with its feedback fields on a tab shown only after the person adds an item, it prints entries such as:

[
  {
    "path": "items[].product",
    "type": "fInput",
    "dataType": "str",
    "required": true
  },
  {
    "path": "city",
    "type": "fSelect",
    "dataType": "str",
    "required": false,
    "options": {
      "remote": "https://api.tepez.co.il/v1.0/optionsSet/572078298b59b1ba2ebcd824/autocomplete"
    }
  },
  {
    "path": "giftCard",
    "type": "fSelect",
    "dataType": "str",
    "required": false,
    "options": [
      { "value": "red", "label": "Red card", "group": "Classic" },
      { "value": "blue", "label": "Blue card", "group": "Classic" },
      { "value": "winter", "label": "Winter card", "group": "Seasonal" }
    ]
  },
  {
    "path": "satisfaction",
    "type": "fMatrix",
    "dataType": "matrix",
    "required": false,
    "relevant": ["model.items.length > 0"],
    "options": [
      { "value": "1", "label": "Bad" },
      { "value": "2", "label": "OK" },
      { "value": "3", "label": "Good" }
    ],
    "questions": ["speed", "quality"]
  },
  {
    "path": "email",
    "type": "fInput",
    "dataType": "str",
    "required": "model.rating < 3",
    "relevant": ["model.items.length > 0"]
  }
]
KeyWhat it tells you
pathThe field's data path. [] stands for any row of a repeating group.
typeThe field type. See Field types.
dataTypeThe type of value the field stores: str, num, date, bool, array, file, sign, color or matrix.
requiredtrue, false, or the expression that makes the field required.
relevantThe conditions of the field and of the containers around it. While any of them is false, the field is hidden and its value is dropped.
optionsThe field's options as { value, label }, with group for grouped options. { "expression": … } when an expression computes them, { "remote": … } when they come from an options set.
questionsThe keys of a matrix's value.

An expression in required or relevant uses the form's own syntax: model is the entry's data. Treat these as hints for a person reading the schema rather than code to run.

Keep in sync

The schema changes when someone edits the form. A form's modifyTime is the last time someone changed the form itself. changedAt also moves when people fill the form, so it changes far more often. Store the modifyTime of each form you use, and generate its schema again when it moves.

GET /form with fields reads the modifyTime of all your forms in one request:

curl -G https://api.tepez.co.il/v1.0/form \
  -H "x-api-key: $TEPEZ_API_KEY" \
  --data-urlencode 'fields=["id","name","modifyTime"]' \
  --data-urlencode 'limit=100'
{
  "data": [
    { "id": "<formId>", "name": "Job application", "modifyTime": "2026-10-01T10:17:19.185Z" },
    { "id": "<formId2>", "name": "Order feedback", "modifyTime": "2026-10-01T10:25:02.446Z" }
  ]
}

modifyTime also moves when someone changes other parts of the form, such as its emails. Compare the new schema with the one you stored, and act only on a real difference: a new required field, a removed field, or a changed list of options.

Troubleshooting

ResponseCause and fix
400 entry_failed_validation, You can't leave this empty.A required field is missing. The modelPath of each message names the field, for example model['fullName']. A required field can depend on other values: check its reqValidator.
400 entry_failed_validation, not in optionsThe value isn't one of the field's option values. Send the value, not the label, and check the form's current options.
400 entry_failed_validation, Invalid data type.The value has the wrong type for the field: a date with a time zone offset or not in ISO 8601, a single string for checkboxes or a multi-select, or text for a number. See Field types.
400 entry_failed_validation, There must be 10 rows or lessA repeating group takes at most 10 rows unless the form sets repMaxRows.
200, but a value is missing from the entryThe key isn't a field of the form, the path is wrong, or a relevant condition hid the field for this entry. A nested key belongs under its section's object only when the section has "secFlat": false.
200, and a required yes/no field holds falseA required yes/no field accepts false. Check for true on your side.

The error message of entry_failed_validation says the problem is probably a bug on our side. For an API call, it almost always means the data you sent broke a rule of the form: read the messages array, as Errors explains. Every response carries an x-tepez-request-id header. Quote it when you contact us.

Email support with the request id

Question about this page? Email support@tepez.co.il

On this page