Inbound webhooks: schemas
Everything a webhook schema can contain: steps or flat fields, every field type and its limits, conditions, defaults and templates.
The schema of a webhook is the contract: it lists the fields a payload may have, their types and their limits. An admin writes it in the admin panel, which also offers templates and checks it as they type. This page is the reference for what a schema can contain. If you only send data, you need it to know what a webhook expects.
Two shapes
A schema has version: 1 and then either steps or fields, never both.
| Shape | Use it for | The payload |
|---|---|---|
steps | A form with several parts, such as identity, background and motivation. | An object keyed by step, each holding that step's fields. |
fields | A flat payload with no steps, such as an event or any non-form data. | The fields directly at the top. |
| Property | Meaning |
|---|---|
version | schema format version, currently 1 |
steps | the steps of a form, in order |
fields | the fields of a flat payload with no steps, for an event or any non-form data |
A form with steps
{
"version": 1,
"steps": [
{
"key": "identity",
"fields": [
{ "key": "firstname", "type": "string", "required": true, "maxLength": 80, "trim": true },
{ "key": "email", "type": "email", "required": true },
{
"key": "status",
"type": "enum",
"required": true,
"options": [
{ "value": "student", "label": "Student" },
{ "value": "professional", "label": "Professional" }
]
}
]
},
{
"key": "background",
"fields": [
{
"key": "university",
"type": "string",
"required": true,
"maxLength": 120,
"condition": { "op": "eq", "field": "identity.status", "value": "student" }
}
]
}
]
}
The payload that matches it:
{
"identity": { "firstname": "Amina", "email": "amina@usthb.dz", "status": "student" },
"background": { "university": "USTHB" }
}
A step has these properties:
| Property | Meaning |
|---|---|
key | the name of this step in the payload |
fields | the fields of this step |
title | human-friendly name, shown in the docs |
description | help text, shown in the docs |
condition | skip this whole step unless the condition holds |
A flat payload
{
"version": 1,
"fields": [
{ "key": "memberId", "type": "string", "required": true, "maxLength": 32 },
{
"key": "plan",
"type": "enum",
"required": false,
"options": [
{ "value": "free", "label": "Free" },
{ "value": "pro", "label": "Pro" }
]
}
]
}
{ "memberId": "42", "plan": "pro" }
Fields
Every field has these properties, whatever its type. key and type are required, and so is required: a field without an explicit true or false is refused when the admin saves the schema, with MISSING_REQUIRED_FLAG.
| Property | Meaning |
|---|---|
key | the name of this field in the payload |
type | what kind of value this field holds |
required | true if a value must be sent |
label | human-friendly name, shown in the docs |
description | help text, shown in the docs |
condition | only ask for this field when the condition holds |
default | value used when none is sent |
A key starts with a letter or underscore, continues with letters, digits and underscores, and is at most 64 characters. Keys are unique within their step or object.
Field types
| Type | What it holds |
|---|---|
string | short text, with optional length, pattern and trimming |
text | long free text |
number | a number, optionally whole and within a range |
boolean | true or false |
email | a validated address, optionally limited to some domains |
url | a validated web address |
phone | a phone number |
date | a calendar date, YYYY-MM-DD |
datetime | an ISO 8601 date and time with a timezone |
enum | one value from a list of options |
multi_enum | several values from a list of options |
object | a group of fields |
array | a repeatable item, needs maxItems |
file | one uploaded file |
files | several uploaded files, needs maxCount |
json | any JSON value, needs maxBytes |
What each type accepts
| Type | Property | Meaning |
|---|---|---|
string | minLength | fewest characters allowed |
string | maxLength | most characters allowed |
string | pattern | regular expression the value must match |
string | trim | strip spaces around the value first |
text | maxLength | most characters allowed |
number | min | smallest value allowed |
number | max | largest value allowed |
number | integer | true to allow whole numbers only |
email | allowedDomains | list of domains the address must belong to |
url | allowedSchemes | list of allowed schemes: "http", "https" |
phone | region | country code the number is for |
date | min | earliest date, YYYY-MM-DD |
date | max | latest date, YYYY-MM-DD |
datetime | min | earliest moment, ISO 8601 with timezone |
datetime | max | latest moment, ISO 8601 with timezone |
enum | options | list of { value, label } choices |
multi_enum | options | list of { value, label } choices |
multi_enum | minSelected | fewest choices to select |
multi_enum | maxSelected | most choices to select |
object | fields | the fields of the group |
array | item | the field repeated for each entry |
array | minItems | fewest entries allowed |
array | maxItems | most entries allowed (required) |
file | accept | list of allowed MIME types |
file | maxSizeBytes | largest file size in bytes |
files | accept | list of allowed MIME types |
files | maxSizeBytes | largest size of each file in bytes |
files | minCount | fewest files allowed |
files | maxCount | most files allowed (required) |
json | maxBytes | largest size in bytes |
Notes on a few types:
stringappliestrimbefore checking lengths and thepattern. Apatternis a regular expression of at most 200 characters, and MCDI stops evaluating one that takes longer than 50 ms.emailhas a practical format check and a maximum of 254 characters. WithallowedDomains, other domains giveDOMAIN_NOT_ALLOWED.phoneaccepts an optional leading+then 6 to 20 digits, spaces, brackets and hyphens.dateisYYYY-MM-DD.datetimeis ISO 8601 and must include a timezone, for example2026-01-31T09:00:00Z.urlwithallowedSchemesof["https"]refuseshttpaddresses.enumandmulti_enumtakeoptions, each with avalueand alabel. Only thevalueis sent in a payload.
Option properties:
| Property | Meaning |
|---|---|
value | the value sent in the payload |
label | human-friendly name, shown in the docs |
Objects and arrays
An object holds a group of fields. An array repeats one item field and needs maxItems, so a payload cannot be unbounded. Here a team lists up to five members, and each member's github address is asked only of developers:
{
"version": 1,
"fields": [
{ "key": "team", "type": "string", "required": true, "maxLength": 60, "trim": true },
{
"key": "members",
"type": "array",
"required": true,
"minItems": 1,
"maxItems": 5,
"item": {
"key": "member",
"type": "object",
"required": true,
"fields": [
{ "key": "name", "type": "string", "required": true, "maxLength": 80 },
{
"key": "role",
"type": "enum",
"required": true,
"options": [
{ "value": "dev", "label": "Developer" },
{ "value": "design", "label": "Designer" }
]
},
{
"key": "github",
"type": "url",
"required": true,
"allowedSchemes": ["https"],
"condition": { "op": "eq", "field": "./role", "value": "dev" }
}
]
}
},
{ "key": "wantsMentor", "type": "boolean", "required": false, "default": false }
]
}
{
"team": "Rocket",
"members": [
{ "name": "Sara", "role": "dev", "github": "https://github.com/sara" },
{ "name": "Lina", "role": "design" }
],
"wantsMentor": true
}
In an error, array entries are addressed by position, for example members[0].github.
Conditions
A condition makes a field, or a whole step, apply only when something else holds. When it does not hold, the field is ignored: it is not required, and a value sent for it is dropped and not stored. Sending github for a designer in the example above succeeds, and the stored submission has no github.
A condition has an operator, op, and what it needs:
| Operator | Holds when the field |
|---|---|
eq | equals the value |
ne | does not equal the value |
gt | greater than the value |
lt | less than the value |
gte | greater than or equal to the value |
lte | less than or equal to the value |
in | is one of the values in a list |
contains | contains the value |
exists | has been given a value |
and | all of the conditions in `of` hold |
or | any of the conditions in `of` holds |
not | the condition in `of` does not hold |
| Property | Meaning |
|---|---|
op | how to compare |
field | path of the field to read, e.g. "identity.status", or "./role" for the same list entry |
value | what to compare it with |
of | the conditions combined by and / or / not |
The field is the path of the field to read. In a form with steps it is step.field, as in identity.status. In a flat schema it is just the key. Inside an array item, ./role means the role of the same entry, which is how each team member's github depends on their own role. A condition can only refer to a field that comes earlier, so a forward reference is refused when the schema is saved.
Combine conditions with and, or and not, which list their conditions in of:
{
"op": "and",
"of": [
{ "op": "eq", "field": "identity.status", "value": "student" },
{ "op": "exists", "field": "identity.email" }
]
}
Defaults and unknown fields
A field with a default takes that value when none is sent, and the default is stored: the first example submission above that omitted wantsMentor was stored with "wantsMentor": false. With "reject unknown fields" on, which is the default, any key the schema does not declare gives UNKNOWN_FIELD, or UNKNOWN_STEP for a stray top-level key in a form with steps.
Limits
| Limit | Value |
|---|---|
| Steps | 20 |
| Fields in the whole schema | 200 |
| Nesting depth of objects and arrays | 5 |
| Length of a field key | 64 characters |
Length of a pattern | 200 characters |
A schema over a limit is refused when the admin saves it, with TOO_MANY_STEPS, TOO_MANY_NODES, TOO_DEEP, KEY_TOO_LONG or PATTERN_TOO_LONG. The payload size is limited too: the API accepts a request body up to 512 KB.
Templates
The admin panel starts a new webhook from one of these. They are real schemas you can use as they are or change.
| Template | Name | What it is |
|---|---|---|
recruitment | Recruitment | Three steps: identity, a background that depends on the status, and motivation. |
workshop | Workshop sign-up | One step: who is coming, their level and topics, and consent. |
event | Event registration | Attendee details plus a list of guests, where a guest can be a member. |
project-event | Project event | No steps: a flat payload that describes something that happened in the project. |
blank | Blank | One step with one field, to start from. |
When a schema is refused
When an admin saves a schema that breaks a rule, the API answers 400 with every problem and its position:
{
"statusCode": 400,
"error": "InvalidSchema",
"errors": [
{
"path": "fields[1].required",
"code": "MISSING_REQUIRED_FLAG",
"message": "`required` must be a boolean"
}
]
}
The codes you are most likely to see are MISSING_REQUIRED_FLAG, MISSING_MAX_ITEMS, MISSING_MAX_COUNT, MISSING_MAX_SIZE, MISSING_MAX_BYTES (a limit the type needs), NO_OPTIONS, DUPLICATE_KEY, INVALID_PATTERN, FORWARD_REFERENCE and UNKNOWN_PROPERTY (a property the type does not have).
The tables on this page come straight from the shared contracts package that the admin editor also uses, so they list exactly the types and properties the editor offers. The API's validator is the final authority.
Source: packages/contracts/src/inbound-webhooks.ts, apps/api/src/modules/inbound-webhooks/schema/schema.validator.ts, apps/api/src/modules/inbound-webhooks/schema/payload.validator.ts, apps/api/src/modules/inbound-webhooks/schema/form-schema.types.ts, apps/api/src/modules/inbound-webhooks/schema/condition.evaluator.ts.