Requirements
Read the requirements schema and render incorporation forms from it, for any jurisdiction.
The requirements response is the form contract for one jurisdiction and company type. It lists every company-level field, every participant role, and every document slot that must be filled before an incorporation can be submitted, with the rules that decide when each one applies. Render your forms from it and the same code serves Singapore, Delaware and every jurisdiction added later.
Fetch it with GET/incorporations/requirements. Schemas are versioned: an incorporation records the requirementsVersion it was created with and is validated against that version for its whole life, so cache schemas by jurisdiction, type and version.
Shape of the response
Four parts, plus identifying keys:
jurisdiction,type,version: which schema this is.blocks: company-level fields, grouped into sections for display. Each block has akey, atitleandfields.participantRequirements: the roles the company needs (DIRECTOR,SHAREHOLDERand so on), how many of each, which participant types may hold them, and what data each person or company must have.documentRequirements: the document slots each participant must fill, by role and participant type.
Abridged, for a Singapore private limited company:
{
"object": "requirements",
"jurisdiction": "SG",
"type": "PRIVATE_LIMITED_COMPANY",
"version": "2026-09-01",
"blocks": [
{
"key": "company",
"title": "Company details",
"fields": [
{
"key": "companyNameOptions",
"kind": "collection",
"label": "Company name options, in order of preference",
"required": true,
"cardinality": { "exact": 3 },
"constraints": { "uniqueItems": true },
"item": { "kind": "scalar", "input": { "type": "text", "placeholder": "Northwind Asia Pte. Ltd." } }
},
{
"key": "financialYearEnd",
"kind": "scalar",
"label": "Financial year end (MM-DD)",
"required": true,
"input": { "type": "text", "pattern": "^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$" }
}
]
},
{
"key": "registeredOffice",
"title": "Registered office",
"fields": [
{
"key": "registeredOfficeAddressSource",
"kind": "scalar",
"label": "Registered office address",
"required": true,
"input": {
"type": "select",
"options": [
{ "value": "getstartedService", "label": "Use the GetStarted registered office service" },
{ "value": "customerProvidedLocation", "label": "Use an address I provide" }
]
}
},
{
"key": "registeredOfficeAddressLocation",
"kind": "resource",
"label": "Registered office location",
"resourceType": "LOCATION",
"modes": ["existingResource", "inlineData"],
"required": { "when": { "fieldKey": "registeredOfficeAddressSource", "equals": "customerProvidedLocation" } },
"appliesWhen": { "fieldKey": "registeredOfficeAddressSource", "equals": "customerProvidedLocation" },
"dataFields": [
{ "key": "line1", "kind": "scalar", "label": "Address line 1", "required": true, "input": { "type": "text" } },
{ "key": "city", "kind": "scalar", "label": "City", "required": true, "input": { "type": "text" } },
{ "key": "postalCode", "kind": "scalar", "label": "Postal code", "required": true, "input": { "type": "text" } },
{ "key": "country", "kind": "scalar", "label": "Country", "required": true, "input": { "type": "country" } }
]
}
]
}
],
"participantRequirements": [
{
"role": "DIRECTOR",
"cardinality": { "min": 1, "max": 12 },
"allowedParticipantTypes": ["INDIVIDUAL"],
"resourceFieldsByParticipantType": {
"INDIVIDUAL": [
{ "key": "firstName", "kind": "scalar", "required": true, "input": { "type": "text" } },
{ "key": "dateOfBirth", "kind": "scalar", "required": true, "input": { "type": "date" } },
{ "key": "nationality", "kind": "scalar", "required": true, "input": { "type": "country" } }
]
}
},
{
"role": "SHAREHOLDER",
"cardinality": { "min": 1, "max": 50 },
"allowedParticipantTypes": ["INDIVIDUAL", "CORPORATE"],
"constraints": { "sumEquals": { "field": "ownershipPercentage", "value": 100 } },
"resourceFieldsByParticipantType": {
"CORPORATE": [
{ "key": "legalName", "kind": "scalar", "required": true, "input": { "type": "text" } },
{ "key": "countryOfIncorporation", "kind": "scalar", "required": true, "input": { "type": "country" } },
{ "key": "registrationNumber", "kind": "scalar", "required": true, "input": { "type": "text" } }
]
}
}
],
"documentRequirements": [
{ "role": "DIRECTOR", "participantType": "INDIVIDUAL", "documentKind": "PASSPORT_SCAN", "required": true },
{ "role": "DIRECTOR", "participantType": "INDIVIDUAL", "documentKind": "PROOF_OF_ADDRESS", "required": true },
{ "role": "SHAREHOLDER", "participantType": "CORPORATE", "documentKind": "CERTIFICATE_OF_INCORPORATION", "required": true }
]
}The full response is in the GET/incorporations/requirements reference.
Where each part goes
Each part of the schema is filled through a different endpoint. Sending data to the wrong one is the most common integration mistake.
blocks[].fields → intake
Every field in every block is an incorporation-level value. Send it to PATCH/incorporations/{incorporationId}/intake under requirements, keyed by the field's key:
{
"requirements": {
"companyNameOptions": ["Northwind Asia Pte. Ltd.", "Northwind Labs Asia Pte. Ltd.", "Northwind Pacific Pte. Ltd."],
"financialYearEnd": "12-31",
"registeredOfficeAddressSource": "getstartedService"
}
}Blocks are only for layout. The block key is not part of the value: companyNameOptions sits directly under requirements, not under requirements.company. Any key that is not a field in the schema returns 400 unknown_field.
participantRequirements → participants
Each entry names a role and how many participants must hold it:
role: the role to attach with POST/incorporations/{incorporationId}/participants.cardinality: how many participants must hold it, asminandmax(orexact). Singapore needs 1 to 12 directors.allowedParticipantTypes:INDIVIDUAL(a person),CORPORATE(a business entity), or both. Singapore directors must be individuals.constraints.sumEquals: a total that the role's values must add up to across all participants. For Singapore shareholders, everyownershipPercentagemust sum to 100.
One participant can hold several roles, so a sole founder can be both director and shareholder on a single participant.
resourceFieldsByParticipantType → persons and business entities
This lists the data that must exist on the person or business entity behind each participant, keyed by participant type. A director's date of birth is a field of the person record, so it goes to POST/customers/{customerId}/persons or PATCH/customers/{customerId}/persons/{personId}. A corporate shareholder's registration number goes to POST/customers/{customerId}/business-entities. Field keys match the person and business entity fields one to one.
Participant data never goes through intake. Sending requirements.directors or requirements.dateOfBirth to PATCH intake returns 400 unknown_field. Create or update the person or business entity, then reference it from a participant. The data is stored once and reused on every incorporation that person or company joins.
documentRequirements → document slots
Each entry is a slot that every participant holding role with type participantType must fill with a file:
- Upload the file with POST
/files. - Put it into the slot with PUT
/incorporations/{incorporationId}/participants/{participantId}/documents/{documentKind}, usingdocumentKindas the path segment.
For a Singapore individual director that means two slots, PASSPORT_SCAN and PROOF_OF_ADDRESS. A corporate shareholder needs CERTIFICATE_OF_INCORPORATION. A participant that holds several roles needs the union of their slots, and each slot is filled once.
Field kinds
Every field has a key, a kind, usually a label, and a required rule. The kind tells you what shape of value to send.
scalar
A single value. input.type says which control to render and what format the value takes:
text: a single-line string. May carrypattern(a regular expression the whole value must match) andplaceholder.textarea: a multi-line string.email: an email address.phone: a phone number in E.164 format, such as+447700900123.date: a calendar date asYYYY-MM-DD.country: an ISO 3166-1 alpha-2 code, such asSGorGB. Render a country picker; send the code.select: one ofinput.options. Show each option'slabel; send itsvalue.number: a JSON number, not a string. May carryminandmax.percent: a JSON number from 0 to 100, such as60for 60%. Not a fraction.
object
A group of named sub-fields, listed in fields. The value is a JSON object keyed by those sub-fields. Sub-fields have their own required rules, which apply once the object itself is being filled.
{
"requirements": {
"shareCapital": { "amount": 10000, "currency": "SGD" }
}
}collection
A list. item describes one element: either a scalar with its own input, or an object with its own fields. The value is a JSON array.
cardinality.exact: the array must have exactly this many items.cardinality.minandcardinality.max: bounds on the number of items.constraints.uniqueItems: no two items may be the same. Strings are compared ignoring case and surrounding whitespace.constraints.sumEquals: for a collection of objects, the named numeric sub-field must add up tovalueacross all items.
Collections are replaced whole on every PATCH. To change one item, send the full array.
A collection of objects with a sum constraint looks like this. shareClasses is an illustration, not a field in the Singapore schema:
{
"key": "shareClasses",
"kind": "collection",
"label": "Share classes",
"required": true,
"cardinality": { "min": 1, "max": 5 },
"constraints": { "uniqueItems": true, "sumEquals": { "field": "percentageOfCapital", "value": 100 } },
"item": {
"kind": "object",
"fields": [
{ "key": "name", "kind": "scalar", "label": "Class name", "required": true, "input": { "type": "text" } },
{ "key": "percentageOfCapital", "kind": "scalar", "label": "Share of capital", "required": true, "input": { "type": "percent" } }
]
}
}A valid value, where 70 and 30 sum to 100:
{
"requirements": {
"shareClasses": [
{ "name": "Ordinary", "percentageOfCapital": 70 },
{ "name": "Preference", "percentageOfCapital": 30 }
]
}
}resource
A reference to another object, or the data to create one. resourceType says which kind of object (for example LOCATION), and modes says which of the two forms the field accepts. The value always carries a mode:
-
existingResource: point at an object you already created.JSON{ "mode": "existingResource", "resourceId": "loc_01J9ZKD2XN9G5H" } -
inlineData: send the data directly. Its shape is given by the field'sdataFields, which work like an object field'sfields.JSON{ "mode": "inlineData", "data": { "line1": "18 Robinson Road", "line2": "#12-01", "city": "Singapore", "postalCode": "048547", "country": "SG" } }
Use existingResource when the address is already stored as a location, for example on an existing business entity. Use inlineData when the user types it into your form.
Conditional logic
Two properties control when a field matters. Both use the same condition: fieldKey names another field in the same intake, and the condition holds when that field's current value equals equals.
requiredistrue,false, or{ "when": { "fieldKey": "…", "equals": "…" } }. Withwhen, the field is mandatory only while the condition holds.appliesWhenis a condition. When it does not hold, the field does not exist for this incorporation.
They are different:
appliesWhenfalse: hide the field. Validation skips it, and any value already stored for it is ignored, so a user who switches away from an option does not leave stale data blocking submit.requiredwithwhen: show the field whenever it applies. It is optional while the condition is false, and mandatory while it is true.
A field with no appliesWhen always applies. In Singapore, registeredOfficeAddressLocation has both properties set to the same condition: it only appears, and is only required, when the user chooses to provide their own address.
Validation
GET/incorporations/{incorporationId}/validation checks the draft against the schema and returns two lists:
missingRequirements: something required is absent: an intake field, a participant role, data on a person or entity, a document.invalidRequirements: something is present but wrong. Each item carries acode, such asunique_items.
Every item has a path that points at what to fix, and a message for developers. isComplete is the source of truth for whether the incorporation can be submitted. Your client-side checks can make the form friendlier, but only isComplete: true means submit will be accepted.
Path grammar
requirements.<key>: an intake field, such asrequirements.companyNameOptions. Collection-level problems (wrong count, duplicates, a sum that is off) point here.requirements.<key>[<index>]: one item of a collection, zero-based, such asrequirements.companyNameOptions[2].participants.<participantId>.<field>: data on the person or entity behind a participant, such asparticipants.par_01J9ZKE7LB2H6J.dateOfBirth. Fix it on the person or entity, not the participant.participants.<participantId>.documents.<documentKind>: a document slot, such asparticipants.par_01J9ZKE7LB2H6J.documents.PROOF_OF_ADDRESS.participantRoles.<role>: a role-level failure, such as too few directors or shareholder percentages that do not sum to 100.
Issues raised after submission use the same grammar, so one routing function serves validation, the submit error and issues. The incorporation flow guide has one.
Worked example: Singapore Pte. Ltd.
Name options: a collection of exactly three unique names
companyNameOptions has cardinality: { exact: 3 } and uniqueItems: true. Render three text inputs, in order of preference. Send all three every time:
{
"requirements": {
"companyNameOptions": ["Northwind Asia Pte. Ltd.", "Northwind Labs Asia Pte. Ltd.", "Northwind Pacific Pte. Ltd."]
}
}Sending two names, or "Northwind Asia Pte. Ltd." and "northwind asia pte. ltd. " in the same list, makes validation report requirements.companyNameOptions.
Registered office source: a select
registeredOfficeAddressSource is a required select with two values:
getstartedService: GetStarted provides the registered office address.customerProvidedLocation: the customer provides their own address.
Registered office location: a resource, required only for your own address
registeredOfficeAddressLocation applies, and is required, only when the source is customerProvidedLocation.
Using the GetStarted service, send only the source. The location field does not apply, so it is neither shown nor validated:
{
"requirements": {
"registeredOfficeAddressSource": "getstartedService"
}
}Using the customer's own address, send the source and the location together, here as inline data:
{
"requirements": {
"registeredOfficeAddressSource": "customerProvidedLocation",
"registeredOfficeAddressLocation": {
"mode": "inlineData",
"data": {
"line1": "18 Robinson Road",
"line2": "#12-01",
"city": "Singapore",
"postalCode": "048547",
"country": "SG"
}
}
}
}If the address already exists as a location, send { "mode": "existingResource", "resourceId": "loc_01J9ZKD2XN9G5H" } as the value instead.
If the user later switches back to getstartedService, the stored location is ignored. Send "registeredOfficeAddressLocation": null as well if you want it cleared.
An individual director
Three requests: create the person with every field in resourceFieldsByParticipantType.INDIVIDUAL, register them as a participant with the DIRECTOR role, and fill their PASSPORT_SCAN slot.
Create the person, including residentialAddress:
curl https://api.getstarted.ae/v1/customers/cus_01J9ZK4R7Q2M8X/persons \
-H "Authorization: Bearer gs_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Maya",
"lastName": "Okafor",
"dateOfBirth": "1989-04-17",
"nationality": "GB",
"email": "maya@northwindlabs.com",
"phone": "+447700900123",
"residentialAddress": {
"line1": "42 Elm Street",
"city": "London",
"postalCode": "N1 7GU",
"country": "GB"
}
}'Register the person as a director:
curl https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/participants \
-H "Authorization: Bearer gs_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"participantType": "INDIVIDUAL",
"resource": { "resourceType": "PERSON", "resourceId": "per_01J9ZKA1M4HD2T" },
"roles": [{ "role": "DIRECTOR" }]
}'Fill the passport slot with a file uploaded earlier:
curl -X PUT https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/participants/par_01J9ZKE7LB2H6J/documents/PASSPORT_SCAN \
-H "Authorization: Bearer gs_test_xxx" \
-H "Content-Type: application/json" \
-d '{ "fileId": "file_01J9ZKG1DV4K8N" }'Validation will still report participants.par_01J9ZKE7LB2H6J.documents.PROOF_OF_ADDRESS until the second director slot is filled, and participantRoles.SHAREHOLDER until at least one shareholder is registered with percentages summing to 100.
Renderer helpers
These TypeScript helpers evaluate the schema's rules in the browser so your form can show, hide and flag fields as the user types. They mirror the server's checks, but the server stays the source of truth: always confirm with GET/incorporations/{incorporationId}/validation before submitting.
export type Primitive = string | number | boolean | null;
export type Condition = { fieldKey: string; equals: Primitive };
export type Requirement = boolean | { when: Condition };
export type Cardinality = { exact?: number; min?: number; max?: number };
export type Input = {
type: "text" | "textarea" | "email" | "phone" | "date" | "country" | "select" | "number" | "percent";
placeholder?: string;
pattern?: string;
min?: number;
max?: number;
options?: { value: string; label: string }[];
};
type FieldBase = {
key: string;
label?: string;
required: Requirement;
appliesWhen?: Condition;
};
export type ScalarField = FieldBase & { kind: "scalar"; input: Input };
export type ObjectField = FieldBase & { kind: "object"; fields: Field[] };
export type CollectionField = FieldBase & {
kind: "collection";
cardinality?: Cardinality;
constraints?: { uniqueItems?: boolean; sumEquals?: { field: string; value: number } };
item: { kind: "scalar"; input: Input } | { kind: "object"; fields: Field[] };
};
export type ResourceField = FieldBase & {
kind: "resource";
resourceType: string;
modes: ("existingResource" | "inlineData")[];
dataFields?: Field[];
};
export type Field = ScalarField | ObjectField | CollectionField | ResourceField;
/** Current intake values, keyed by field key: the same object you PATCH under `requirements`. */
export type Values = Record<string, unknown>;
function conditionHolds(condition: Condition, values: Values): boolean {
return values[condition.fieldKey] === condition.equals;
}
/** False means: hide the field, skip it in validation, ignore any stored value. */
export function evalAppliesWhen(field: Pick<Field, "appliesWhen">, values: Values): boolean {
return field.appliesWhen === undefined || conditionHolds(field.appliesWhen, values);
}
/** Whether the field must be filled right now. A field that does not apply is never required. */
export function evalRequired(field: Pick<Field, "required" | "appliesWhen">, values: Values): boolean {
if (!evalAppliesWhen(field, values)) return false;
if (typeof field.required === "boolean") return field.required;
return conditionHolds(field.required.when, values);
}
/**
* Returns a copy of `items` padded with empty items up to `exact` (or `min`),
* and trimmed down to `exact` (or `max`). Use it to decide how many inputs to render.
*/
export function padToCardinality<T>(
items: readonly T[],
cardinality: Cardinality | undefined,
makeEmpty: () => T,
): T[] {
const out = items.slice();
if (!cardinality) return out;
const floor = cardinality.exact ?? cardinality.min ?? 0;
const ceiling = cardinality.exact ?? cardinality.max ?? Number.POSITIVE_INFINITY;
while (out.length < floor) out.push(makeEmpty());
if (out.length > ceiling) out.length = ceiling;
return out;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
const entries = Object.keys(record)
.sort()
.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`);
return `{${entries.join(",")}}`;
}
return String(JSON.stringify(value));
}
function uniquenessKey(item: unknown): string | null {
if (item === null || item === undefined) return null;
if (typeof item === "string") {
const normalized = item.trim().toLowerCase();
return normalized === "" ? null : `s:${normalized}`;
}
return `j:${stableStringify(item)}`;
}
/**
* Index of the first item that repeats an earlier one, or -1.
* Strings compare trimmed and case-insensitively; objects compare by JSON with sorted keys.
* Blank items are skipped: they are missing, not duplicates.
*/
export function findUniqueItemsViolation(items: readonly unknown[]): number {
const seen = new Set<string>();
for (let i = 0; i < items.length; i++) {
const key = uniquenessKey(items[i]);
if (key === null) continue;
if (seen.has(key)) return i;
seen.add(key);
}
return -1;
}Wired into a render loop, the helpers turn the schema and the current values into a plan your components can draw:
type Block = { key: string; title: string; fields: Field[] };
type Row = {
field: Field;
required: boolean;
items?: unknown[]; // collections: the padded list to render one input per item
duplicateAt?: number; // collections with uniqueItems: index to flag
};
export function planForm(blocks: Block[], values: Values): { key: string; title: string; rows: Row[] }[] {
return blocks.map((block) => ({
key: block.key,
title: block.title,
rows: block.fields
.filter((field) => evalAppliesWhen(field, values))
.map((field): Row => {
const required = evalRequired(field, values);
if (field.kind !== "collection") return { field, required };
const item = field.item;
const stored = Array.isArray(values[field.key]) ? (values[field.key] as unknown[]) : [];
const items = padToCardinality<unknown>(stored, field.cardinality, () => (item.kind === "scalar" ? "" : {}));
const dup = field.constraints?.uniqueItems ? findUniqueItemsViolation(items) : -1;
return { field, required, items, duplicateAt: dup === -1 ? undefined : dup };
}),
}));
}
// With the Singapore schema and { registeredOfficeAddressSource: "getstartedService" },
// the registered office block has one row. Switch the value to "customerProvidedLocation"
// and a second, required row appears for registeredOfficeAddressLocation.Re-run planForm on every change. When the user saves, PATCH the values, then call GET/incorporations/{incorporationId}/validation and show its results next to the matching inputs.