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 a key, a title and fields.
  • participantRequirements: the roles the company needs (DIRECTOR, SHAREHOLDER and 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:

JSON
{
  "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:

JSON
{
  "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, as min and max (or exact). 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, every ownershipPercentage must 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.

Important

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:

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 carry pattern (a regular expression the whole value must match) and placeholder.
  • textarea: a multi-line string.
  • email: an email address.
  • phone: a phone number in E.164 format, such as +447700900123.
  • date: a calendar date as YYYY-MM-DD.
  • country: an ISO 3166-1 alpha-2 code, such as SG or GB. Render a country picker; send the code.
  • select: one of input.options. Show each option's label; send its value.
  • number: a JSON number, not a string. May carry min and max.
  • percent: a JSON number from 0 to 100, such as 60 for 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.

JSON
{
  "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.min and cardinality.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 to value across 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:

JSON
{
  "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:

JSON
{
  "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's dataFields, which work like an object field's fields.

    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.

  • required is true, false, or { "when": { "fieldKey": "…", "equals": "…" } }. With when, the field is mandatory only while the condition holds.
  • appliesWhen is a condition. When it does not hold, the field does not exist for this incorporation.

They are different:

  • appliesWhen false: 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.
  • required with when: 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 a code, such as unique_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 as requirements.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 as requirements.companyNameOptions[2].
  • participants.<participantId>.<field>: data on the person or entity behind a participant, such as participants.par_01J9ZKE7LB2H6J.dateOfBirth. Fix it on the person or entity, not the participant.
  • participants.<participantId>.documents.<documentKind>: a document slot, such as participants.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:

JSON
{
  "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:

JSON
{
  "requirements": {
    "registeredOfficeAddressSource": "getstartedService"
  }
}

Using the customer's own address, send the source and the location together, here as inline data:

JSON
{
  "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.

TypeScript
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:

TypeScript
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.