Build an incorporation flow

Every step from a new customer to a registered company, using a Singapore private limited company as the example.

This guide forms a Singapore private limited company (Pte. Ltd.) for Northwind Labs from start to finish. The company has one director, Maya Okafor, who holds 60% of the shares; the other 40% is held by Northwind Holdings Ltd, an existing UK company. The same steps work for every jurisdiction. Only the requirements schema changes.

Run it with a gs_test_ key. Nothing is filed in the sandbox, and the incorporation still moves through every stage so you can test your webhook handling.

The Node and Python samples assume this setup:

const API = "https://api.getstarted.ae/v1";
const headers = {
  Authorization: "Bearer gs_test_xxx",
  "Content-Type": "application/json",
};

1. Create a customer

The customer is the account on your platform the company is formed for. Keep its id against your own account record.

POST/customers
curl https://api.getstarted.ae/v1/customers \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8d3f6a1c-2b4e-4f7a-9c0d-1e5b7a9c3f21" \
  -d '{
    "name": "Northwind Labs",
    "email": "ops@northwindlabs.com",
    "externalReference": "acct_48213",
    "metadata": { "plan": "growth" }
  }'
JSON
{
  "id": "cus_01J9ZK4R7Q2M8X",
  "object": "customer",
  "name": "Northwind Labs",
  "email": "ops@northwindlabs.com",
  "externalReference": "acct_48213",
  "metadata": { "plan": "growth" },
  "livemode": false,
  "createdAt": "2026-09-20T08:14:22Z"
}

2. Pick a jurisdiction from the catalog

The catalog lists every jurisdiction and the company types it offers. Build your country picker from it. Types with available: false are coming soon; show them, but do not let users start one.

GET/incorporations/catalog
curl https://api.getstarted.ae/v1/incorporations/catalog \
  -H "Authorization: Bearer gs_test_xxx"

The Singapore entry, and one that is not open yet:

JSON
{
  "object": "list",
  "data": [
    {
      "jurisdiction": "SG",
      "country": "SG",
      "name": "Singapore",
      "types": [{ "type": "PRIVATE_LIMITED_COMPANY", "name": "Private limited company (Pte. Ltd.)", "available": true }]
    },
    {
      "jurisdiction": "CH",
      "country": "CH",
      "name": "Switzerland",
      "types": [{ "type": "GMBH", "name": "Limited liability company (GmbH)", "available": false }]
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

The pair you carry forward is jurisdiction: "SG" and type: "PRIVATE_LIMITED_COMPANY".

3. Fetch requirements

The requirements schema is the form contract for that pair: every company-level field, every participant role, every document slot. Render your forms from it instead of hard-coding Singapore. The Requirements guide covers the schema in full.

GET/incorporations/requirements
curl "https://api.getstarted.ae/v1/incorporations/requirements?jurisdiction=SG&type=PRIVATE_LIMITED_COMPANY" \
  -H "Authorization: Bearer gs_test_xxx"

For Singapore it says, among other things: three unique company name options, a business activity, a financial year end, a registered office; at least one individual DIRECTOR with a passport scan and proof of address; shareholders whose ownershipPercentage adds up to 100; and a certificate of incorporation for any corporate shareholder.

4. Create the incorporation

This opens a DRAFT. Nothing is filed until you submit.

POST/incorporations
curl https://api.getstarted.ae/v1/incorporations \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2c7e9b40-5d1a-4e63-8f2b-6a0c4d8e1b57" \
  -d '{
    "customerId": "cus_01J9ZK4R7Q2M8X",
    "jurisdiction": "SG",
    "type": "PRIVATE_LIMITED_COMPANY",
    "metadata": { "internalRef": "NW-SG-01" }
  }'
JSON
{
  "id": "inc_01J9ZK7C3VYB5N",
  "object": "incorporation",
  "customerId": "cus_01J9ZK4R7Q2M8X",
  "jurisdiction": "SG",
  "type": "PRIVATE_LIMITED_COMPANY",
  "status": "DRAFT",
  "stage": "draft",
  "requirementsVersion": "2026-09-01",
  "requirements": {},
  "businessEntityId": null,
  "submittedAt": null,
  "completedAt": null,
  "metadata": { "internalRef": "NW-SG-01" },
  "livemode": false,
  "createdAt": "2026-09-20T08:31:57Z",
  "updatedAt": "2026-09-20T08:31:57Z"
}

requirementsVersion pins the schema. If the Singapore schema changes later, this draft keeps validating against 2026-09-01.

5. Fill intake

Company-level answers go under requirements, keyed by the key of each field in blocks[].fields. Save as the user moves through your form; you do not need everything at once.

PATCH/incorporations/{incorporationId}/intake
curl -X PATCH https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/intake \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "requirements": {
      "companyNameOptions": [
        "Northwind Asia Pte. Ltd.",
        "Northwind Labs Asia Pte. Ltd.",
        "Northwind Pacific Pte. Ltd."
      ],
      "businessActivity": "62011",
      "financialYearEnd": "12-31",
      "registeredOfficeAddressSource": "getstartedService"
    }
  }'

The merge rules:

  • Key by key. Keys you leave out keep their stored value. Sending only financialYearEnd changes only financialYearEnd.
  • null clears. Send "businessActivity": null to remove a stored value.
  • Collections are replaced whole. To change the second name option, send all three names.
  • Unknown keys are rejected. A key that is not a field in this incorporation's schema returns 400 unknown_field, and nothing in the request is saved.

The last rule catches a common mistake: sending people data through intake.

JSON
{
  "error": {
    "type": "invalid_request_error",
    "code": "unknown_field",
    "message": "requirements.directors is not a field in the SG PRIVATE_LIMITED_COMPANY schema (version 2026-09-01).",
    "param": "requirements.directors",
    "requestId": "req_01J9ZKM2QF7C4D"
  }
}

Directors and shareholders are not intake fields. They are participants, covered in the next two steps.

6. Create persons and business entities

People and companies are stored under the customer, separately from any incorporation, so they can be reused. Create each one with the fields that participantRequirements[].resourceFieldsByParticipantType asks for.

Maya is an individual, so she is a person:

POST/customers/{customerId}/persons
curl https://api.getstarted.ae/v1/customers/cus_01J9ZK4R7Q2M8X/persons \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a41b7e2d-93c8-4f05-b6e1-7d2c0a9f5e38" \
  -d '{
    "firstName": "Maya",
    "lastName": "Okafor",
    "dateOfBirth": "1989-04-17",
    "nationality": "GB",
    "email": "maya@northwindlabs.com",
    "phone": "+447700900123",
    "residentialAddress": {
      "line1": "42 Elm Street",
      "line2": null,
      "city": "London",
      "region": null,
      "postalCode": "N1 7GU",
      "country": "GB"
    }
  }'

Northwind Holdings Ltd is a company, so it is a business entity:

POST/customers/{customerId}/business-entities
curl https://api.getstarted.ae/v1/customers/cus_01J9ZK4R7Q2M8X/business-entities \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6e9d1f3a-0c7b-4a82-95d4-b3f8e2a1c706" \
  -d '{
    "legalName": "Northwind Holdings Ltd",
    "entityType": "PRIVATE_LIMITED_COMPANY",
    "countryOfIncorporation": "GB",
    "registrationNumber": "14820391",
    "dateOfIncorporation": "2023-02-09",
    "email": "legal@northwindlabs.com"
  }'

7. Register participants

A participant attaches a person or entity to this incorporation with roles. Maya is a director and holds 60%.

POST/incorporations/{incorporationId}/participants
curl https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/participants \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: c5a08e4f-17d2-4b9e-8a3c-5f6d2e0b9a14" \
  -d '{
    "participantType": "INDIVIDUAL",
    "resource": { "resourceType": "PERSON", "resourceId": "per_01J9ZKA1M4HD2T" },
    "roles": [
      { "role": "DIRECTOR" },
      { "role": "SHAREHOLDER", "ownershipPercentage": 60 }
    ]
  }'
JSON
{
  "id": "par_01J9ZKE7LB2H6J",
  "object": "participant",
  "incorporationId": "inc_01J9ZK7C3VYB5N",
  "participantType": "INDIVIDUAL",
  "resource": { "resourceType": "PERSON", "resourceId": "per_01J9ZKA1M4HD2T" },
  "roles": [
    { "role": "DIRECTOR" },
    { "role": "SHAREHOLDER", "ownershipPercentage": 60 }
  ],
  "documents": [],
  "createdAt": "2026-09-20T08:45:18Z",
  "updatedAt": "2026-09-20T08:45:18Z"
}

Register Northwind Holdings Ltd the same way, with participantType: "CORPORATE", resourceType: "BUSINESS_ENTITY", resourceId: "ent_01J9ZKC5TR8F4K" and a single SHAREHOLDER role at 40. It comes back as par_01J9ZKF4SC3J7M.

Things that go wrong here:

  • 409 participant_exists: the person or entity is already on this incorporation. Change its roles with PATCH/incorporations/{incorporationId}/participants/{participantId} instead.
  • 422 role_not_allowed: the role is not in the schema, or the participant type is not allowed for it. In Singapore, a CORPORATE participant cannot be a DIRECTOR.

8. Upload files

Upload each document once as multipart/form-data with a purpose. PDF, JPEG and PNG up to 20 MB.

POST/files
curl https://api.getstarted.ae/v1/files \
  -H "Authorization: Bearer gs_test_xxx" \
  -F file=@maya-okafor-passport.pdf \
  -F purpose=IDENTITY_DOCUMENT
JSON
{
  "id": "file_01J9ZKG1DV4K8N",
  "object": "file",
  "filename": "maya-okafor-passport.pdf",
  "contentType": "application/pdf",
  "size": 482113,
  "purpose": "IDENTITY_DOCUMENT",
  "sha256": "9f2c4d1e7a0b3c58e6f14a27d90b5c3e81f62a4d7c09e5b1a38f6d2c4e7b9a01",
  "createdAt": "2026-09-20T08:50:44Z"
}

Upload Northwind Holdings' certificate of incorporation the same way with purpose=CORPORATE_DOCUMENT. It comes back as file_01J9ZKG9PE5L9Q.

9. Attach documents

A file does nothing until it fills a slot. Put it into the participant's slot for the right documentKind. The slots each participant needs are listed in documentRequirements.

PUT/incorporations/{incorporationId}/participants/{participantId}/documents/{documentKind}
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" }'

The participant now lists the document:

JSON
{
  "id": "par_01J9ZKE7LB2H6J",
  "object": "participant",
  "incorporationId": "inc_01J9ZK7C3VYB5N",
  "participantType": "INDIVIDUAL",
  "resource": { "resourceType": "PERSON", "resourceId": "per_01J9ZKA1M4HD2T" },
  "roles": [
    { "role": "DIRECTOR" },
    { "role": "SHAREHOLDER", "ownershipPercentage": 60 }
  ],
  "documents": [
    { "documentKind": "PASSPORT_SCAN", "fileId": "file_01J9ZKG1DV4K8N", "attachedAt": "2026-09-20T08:52:30Z" }
  ],
  "createdAt": "2026-09-20T08:45:18Z",
  "updatedAt": "2026-09-20T08:52:30Z"
}

Put file_01J9ZKG9PE5L9Q into par_01J9ZKF4SC3J7M's CERTIFICATE_OF_INCORPORATION slot the same way. Putting a kind that none of the participant's roles need returns 422 document_kind_not_required.

10. Check validation

Ask the API what is still missing. Call it whenever the user reaches a review screen, and before every submit.

GET/incorporations/{incorporationId}/validation
curl https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/validation \
  -H "Authorization: Bearer gs_test_xxx"

At this point Maya's proof of address has not been uploaded. Say a user has also edited the name options so two of them match:

JSON
{
  "object": "validation",
  "incorporationId": "inc_01J9ZK7C3VYB5N",
  "isComplete": false,
  "missingRequirements": [
    {
      "path": "participants.par_01J9ZKE7LB2H6J.documents.PROOF_OF_ADDRESS",
      "message": "A PROOF_OF_ADDRESS document is required for each DIRECTOR."
    }
  ],
  "invalidRequirements": [
    {
      "path": "requirements.companyNameOptions",
      "code": "unique_items",
      "message": "Each company name option must be different."
    }
  ],
  "checkedAt": "2026-09-20T08:55:02Z"
}

Each path tells you which screen and which input to send the user to. Map it once and reuse it for validation results, the submit error and issues:

TypeScript
type Target =
  | { screen: "intake"; fieldKey: string; index?: number }
  | { screen: "participant"; participantId: string; field: string }
  | { screen: "participantDocument"; participantId: string; documentKind: string }
  | { screen: "participantRoles"; role: string }
  | { screen: "unknown"; path: string };

export function locate(path: string): Target {
  const [head, ...rest] = path.split(".");

  if (head === "requirements" && rest.length > 0) {
    const m = /^([^[\]]+)(?:\[(\d+)\])?$/.exec(rest[0]);
    if (m) return { screen: "intake", fieldKey: m[1], index: m[2] === undefined ? undefined : Number(m[2]) };
  }

  if (head === "participants" && rest.length >= 2) {
    const [participantId, next, ...tail] = rest;
    if (next === "documents" && tail.length === 1) {
      return { screen: "participantDocument", participantId, documentKind: tail[0] };
    }
    return { screen: "participant", participantId, field: [next, ...tail].join(".") };
  }

  if (head === "participantRoles" && rest.length === 1) {
    return { screen: "participantRoles", role: rest[0] };
  }

  return { screen: "unknown", path };
}

// locate("participants.par_01J9ZKE7LB2H6J.documents.PROOF_OF_ADDRESS")
//   → { screen: "participantDocument", participantId: "par_01J9ZKE7LB2H6J", documentKind: "PROOF_OF_ADDRESS" }
// locate("requirements.companyNameOptions")
//   → { screen: "intake", fieldKey: "companyNameOptions", index: undefined }

Show message next to the input. For a participants.… path that is not a document, the data lives on the person or business entity behind the participant, so the fix is an update to that person or entity, not to intake.

Fix both here: upload Maya's proof of address with purpose=ADDRESS_DOCUMENT and put it into her PROOF_OF_ADDRESS slot, then PATCH intake with three different names. Validation now returns:

JSON
{
  "object": "validation",
  "incorporationId": "inc_01J9ZK7C3VYB5N",
  "isComplete": true,
  "missingRequirements": [],
  "invalidRequirements": [],
  "checkedAt": "2026-09-20T09:03:41Z"
}

11. Submit

Submitting locks the draft and starts processing. In live, this is the point where real filings begin.

POST/incorporations/{incorporationId}/submit
curl -X POST https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/submit \
  -H "Authorization: Bearer gs_test_xxx"
JSON
{
  "id": "inc_01J9ZK7C3VYB5N",
  "object": "incorporation",
  "customerId": "cus_01J9ZK4R7Q2M8X",
  "jurisdiction": "SG",
  "type": "PRIVATE_LIMITED_COMPANY",
  "status": "SUBMITTED",
  "stage": "submitted",
  "requirementsVersion": "2026-09-01",
  "requirements": {
    "companyNameOptions": ["Northwind Asia Pte. Ltd.", "Northwind Labs Asia Pte. Ltd.", "Northwind Pacific Pte. Ltd."],
    "businessActivity": "62011",
    "financialYearEnd": "12-31",
    "registeredOfficeAddressSource": "getstartedService"
  },
  "businessEntityId": null,
  "submittedAt": "2026-09-20T09:04:15Z",
  "completedAt": null,
  "metadata": { "internalRef": "NW-SG-01" },
  "livemode": false,
  "createdAt": "2026-09-20T08:31:57Z",
  "updatedAt": "2026-09-20T09:04:15Z"
}

Submit is idempotent: calling it again on a submitted incorporation returns it unchanged with 200. After submit, intake, participants and documents are locked (409 incorporation_locked), except for whatever an open issue points at.

12. Track status and issues

Register a webhook endpoint once per environment and GetStarted will tell you when anything changes. See Webhooks for signature verification.

POST/webhooks
curl https://api.getstarted.ae/v1/webhooks \
  -H "Authorization: Bearer gs_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.northwindlabs.com/hooks/getstarted",
    "events": [
      "incorporation.status_changed",
      "incorporation.issue_created",
      "incorporation.issue_resolved",
      "incorporation.completed"
    ],
    "description": "Production incorporation events"
  }'

As the incorporation moves, you receive incorporation.status_changed:

JSON
{
  "id": "evt_01J9ZKK8HY8P3T",
  "object": "event",
  "type": "incorporation.status_changed",
  "livemode": false,
  "createdAt": "2026-09-23T06:42:37Z",
  "data": {
    "incorporationId": "inc_01J9ZK7C3VYB5N",
    "customerId": "cus_01J9ZK4R7Q2M8X",
    "previousStatus": "IN_PROGRESS",
    "status": "IN_PROGRESS",
    "previousStage": "compliance_review",
    "stage": "name_reservation"
  }
}

Draw your progress tracker from GET/incorporations/status-workflow, and use GET/incorporations/{incorporationId}/status for the current stage and full history whenever you need the truth rather than the last event.

Handling an issue

If something needs fixing during processing, you receive incorporation.issue_created and the status becomes ACTION_REQUIRED. List open issues to see what and where:

GET/incorporations/{incorporationId}/issues
curl "https://api.getstarted.ae/v1/incorporations/inc_01J9ZK7C3VYB5N/issues?status=OPEN" \
  -H "Authorization: Bearer gs_test_xxx"
JSON
{
  "object": "list",
  "data": [
    {
      "id": "iss_01J9ZKH6FW6M1R",
      "object": "issue",
      "incorporationId": "inc_01J9ZK7C3VYB5N",
      "status": "OPEN",
      "category": "DOCUMENT",
      "path": "participants.par_01J9ZKE7LB2H6J.documents.PASSPORT_SCAN",
      "message": "The passport scan is cropped. Upload a scan that shows all four corners of the photo page.",
      "createdAt": "2026-09-22T10:02:11Z",
      "resolvedAt": null
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

The issue's path uses the same grammar as validation, so locate() from step 10 sends the user to the right place. Here, upload a new passport scan and put it into the PASSPORT_SCAN slot; the lock is lifted for exactly the slot the issue names. There is no endpoint to close an issue. Once the data at its path is fixed it resolves, you receive incorporation.issue_resolved, and processing resumes.

Completion

When the company is registered you receive incorporation.completed. The incorporation's businessEntityId now points at the new company. Read its registration number, certificate and identifiers from the business entity: