# Create a company
Source: https://docs.doola.com/api/api-reference/companies/create-a-company
/api-reference/openapi.json post /v1/partner/companies
Starts a company formation for one of your customers. Set `entityType` to `LLC` (the default) or `CCorp`: an LLC takes `members`, while a CCorp takes `executiveMembers` and `ccorpValuation`. Idempotent on the `Idempotency-Key` header: a successful create is replayed for the same key, so retries never start a duplicate formation. If a create fails before it commits, the key is released — fix the request and retry with the same key.
# Get a company
Source: https://docs.doola.com/api/api-reference/companies/get-a-company
/api-reference/openapi.json get /v1/partner/companies/{companyId}
Returns a single company by doola company ID, including formation status, EIN (once issued), members, addresses, and services.
# List companies
Source: https://docs.doola.com/api/api-reference/companies/list-companies
/api-reference/openapi.json get /v1/partner/companies
Lists companies for the authenticated partner tenant. Supports free-text search, filtering by customer, and pagination (page size capped at 100).
# Get a company's compliance calendar
Source: https://docs.doola.com/api/api-reference/compliance/get-a-companys-compliance-calendar
/api-reference/openapi.json get /v1/partner/companies/{companyId}/compliance/calendar
Returns the company's compliance obligations. Today this is the single annual report, with its next due date and a derived status (UPCOMING, OVERDUE, NOT_YET_DUE, or UNSUPPORTED_STATE). Read-only — never triggers filing or recomputation. Returns 404 when the company does not exist or is not owned by the calling partner.
# Create a customer
Source: https://docs.doola.com/api/api-reference/customers/create-a-customer
/api-reference/openapi.json post /v1/partner/customers
Creates a customer under the authenticated partner tenant. Returns `201 Created` for a new customer, or `200 OK` when a customer with the same email already exists and is returned instead of duplicated (`created: false`). Idempotent on the `Idempotency-Key` header: replaying the same key returns the original result — the same response and status as the first call — never a duplicate. If a create fails before it commits, the key is released; fix the request and retry with the same key.
# Get a customer
Source: https://docs.doola.com/api/api-reference/customers/get-a-customer
/api-reference/openapi.json get /v1/partner/customers/{customerId}
Returns a single customer by doola customer ID, including the companies that customer owns.
# List customers
Source: https://docs.doola.com/api/api-reference/customers/list-customers
/api-reference/openapi.json get /v1/partner/customers
Lists customers for the authenticated partner tenant. Supports free-text search and pagination (page size capped at 100).
# Get a document download URL
Source: https://docs.doola.com/api/api-reference/documents/get-a-document-download-url
/api-reference/openapi.json get /v1/partner/companies/{doolaCompanyId}/documents/{documentId}
Returns document metadata plus a short-lived, pre-signed `downloadUrl` for the file. Only documents currently returned by the list endpoint can be downloaded: a deleted `documentId`, or the `documentId` of a superseded `SignedSS4` or `SignedForm8821`, responds with 404. Always take the `documentId` from the list endpoint rather than caching one across polls.
# List company documents
Source: https://docs.doola.com/api/api-reference/documents/list-company-documents
/api-reference/openapi.json get /v1/partner/companies/{doolaCompanyId}/documents
Returns the documents available for a company (metadata only), newest first. A company accumulates documents over its lifetime and every one of them is listed — several `Mail` scans, several state notices, and so on. The one exception is a document the founder signs: a `SignedSS4` or `SignedForm8821` can be signed again after a correction or a voided session, and only the most recent version of each is returned so there is never any doubt which one is current.
# Complete EIN creation (sandbox)
Source: https://docs.doola.com/api/api-reference/playground-sandbox-only/complete-ein-creation-sandbox
/api-reference/openapi.json post /v1/partner/playground/companies/{companyId}/eincreation/complete
Simulates the IRS issuing the EIN: sets the company's EIN and uploads the EIN letter. Fires `company_ein_issued` (first issuance only) and `document_einletter_uploaded`, and advances the EinCreation service to Completed. `triggeredEvents` reflects what this call actually fired, so a repeat call lists only `document_einletter_uploaded`. Sandbox only.
# Complete Formation (sandbox)
Source: https://docs.doola.com/api/api-reference/playground-sandbox-only/complete-formation-sandbox
/api-reference/openapi.json post /v1/partner/playground/companies/{companyId}/formation/complete
Simulates the State confirming formation: records the filing date, adds a test admin note, and uploads the Articles of Organization. Fires `company_formation_completed` and `document_aoo_uploaded`, and advances the Formation service to Completed. Processing the Articles also generates the entity type's governance document shortly after — Corporate Bylaws for a CCorp (firing `document_corporatebylaws_uploaded`) or the Operating Agreement for an LLC (firing `document_operatingagreement_uploaded`) — as in production. Sandbox only.
# List countries
Source: https://docs.doola.com/api/api-reference/reference-data/list-countries
/api-reference/openapi.json get /v1/partner/references/countries
Returns supported countries with ISO codes.
# List NAICS codes
Source: https://docs.doola.com/api/api-reference/reference-data/list-naics-codes
/api-reference/openapi.json get /v1/partner/references/naics-codes
Returns the NAICS industry codes accepted by the create-company endpoint.
# List state filing fees
Source: https://docs.doola.com/api/api-reference/reference-data/list-state-filing-fees
/api-reference/openapi.json get /v1/partner/references/state-fees
Returns the formation filing fee per state, in cents, for the requested entity type (LLC or CCorp). Defaults to LLC when omitted.
# List states
Source: https://docs.doola.com/api/api-reference/reference-data/list-states
/api-reference/openapi.json get /v1/partner/references/states
Returns supported US states with abbreviations.
# List a company's required actions
Source: https://docs.doola.com/api/api-reference/required-actions/list-a-companys-required-actions
/api-reference/openapi.json get /v1/partner/companies/{companyId}/required-actions
Every required action for the company, most recently changed first, including ones already resolved or rejected. Use this to reconcile after missing webhooks. Histories are omitted here; read a single action for those.
# List open required actions across your companies
Source: https://docs.doola.com/api/api-reference/required-actions/list-open-required-actions-across-your-companies
/api-reference/openapi.json get /v1/partner/required-actions
Every open required action across all of your companies, most recently changed first. Closed actions are excluded — use the per-company listing to reconcile those. `total` carries the tenant-wide open count whatever the page size. Histories are omitted; read a single action for those.
# Resolve a required action
Source: https://docs.doola.com/api/api-reference/required-actions/resolve-a-required-action
/api-reference/openapi.json post /v1/partner/companies/{companyId}/required-actions/{requiredActionId}/resolution
Submit what doola asked for. The action moves to `submitted` and doola takes it from there; you will receive further webhooks if anything else is needed. Actions resolved by another flow, such as an SS4 re-signature, are not accepted here.
# Retrieve a required action
Source: https://docs.doola.com/api/api-reference/required-actions/retrieve-a-required-action
/api-reference/openapi.json get /v1/partner/companies/{companyId}/required-actions/{requiredActionId}
One required action with its full history, so you can see when it was raised, whether doola reached your endpoint, and how many rounds it has taken.
# Create a signature session
Source: https://docs.doola.com/api/api-reference/signatures/create-a-signature-session
/api-reference/openapi.json post /v1/partner/companies/{companyId}/signatures
Generates an embedded signing URL for the specified document type. Always mints a fresh URL — prior unsigned sessions for the same document are voided afterwards on a best-effort basis. Returns 409 if the document has already been signed, or 422 if the company is not eligible (e.g., a member has an SSN or ITIN on file).
# Authentication
Source: https://docs.doola.com/api/authentication
How to authenticate requests to the doola Partner API.
Every request is authenticated with an API key sent in the `Authorization` header.
```bash theme={null}
Authorization: dk_live_your_api_key_here
Content-Type: application/json
```
## API keys
API keys are issued by the doola team, one per environment.
| Prefix | Environment |
| ---------- | -------------------------------------- |
| `dk_test_` | Sandbox (`https://api.test.doola.com`) |
| `dk_live_` | Production (`https://api.doola.com`) |
Treat API keys as secrets. Use them only from your backend, never in client side code. A sandbox key does not work against production, and the reverse is also true.
Manage your keys, webhook endpoint, and event subscriptions in the doola Partner Portal, including rotating a key. Don't have access yet? [Get API access](https://www.doola.com/partner-with-us/).
## Request headers
| Header | Required | Description |
| ----------------- | --------- | --------------------------------------------------------- |
| `Authorization` | Yes | Your API key, sent as the raw value (no `Bearer` prefix). |
| `Content-Type` | On writes | `application/json` for `POST` requests. |
| `Idempotency-Key` | On writes | Required on create endpoints. Makes retries safe. |
| `x-request-id` | No | Optional correlation id. Echoed back on the response. |
## Idempotency
The create endpoints (`POST /v1/partner/customers` and `POST /v1/partner/companies`) require an `Idempotency-Key`.
* Use a unique value per logical operation, for example a UUID tied to your own record.
* Retrying with the same key and the same body returns the original result and never creates a duplicate.
* If a create fails before it commits — a validation or pre-check `4xx`, or a transient `5xx` — the key is released. Fix the request and retry with the **same** key (you do not need a new one).
* Once a create has succeeded, reusing its key with a different body returns `409 E_IDEMPOTENCY_KEY_REUSED`.
* While a create with a given key is still in flight, a concurrent retry of that key returns `409 E_IDEMPOTENCY_IN_PROGRESS`; retry after a short delay.
# Changelog
Source: https://docs.doola.com/api/changelog
Updates and changes to the doola Partner API.
`description` on [Create company](/api/domain-objects#company) is now capped at **50 characters**, down from 250. Over the cap the request is rejected with `400 E_DESCRIPTION_INVALID` and the message "Description is too long, must be at most 50 characters".
The cap matches the IRS EIN application's business-activity field, which rejects anything longer.
doola can now ask you for a **Form 8821 signature** on a company that was never required to sign one at formation, most often a US founder whose EIN could not be obtained online. It arrives as a new required action, `FORMATION_SIGNATURE_FORM8821_REQUIRED`, announced by a new webhook event, `signature_form8821_required`, and is resolved the way a Form 8821 is signed at formation: create a signature session with `documentType: "FORM8821"` and present the link. See [Form 8821 signature needed](/api/required-actions/form8821-required).
Two contract notes. `signatureRequirements` can now carry a `FORM8821` entry for a **US founder** (previously always empty), so do not read a non-empty array as proof of a non-US founder. And the signature-session endpoint accepts `FORM8821` for any company with this action open; without one, eligibility is unchanged. No existing payload field changed.
Generating and rotating your **webhook signing secret** is now self-serve, under **Settings → Webhook URL** in the Partner Portal. It no longer requires emailing doola. The secret is still shown once, and a rotation invalidates the previous secret immediately, so deploy the new value before you rotate. Generating one requires an active API key on the tenant.
The webhooks guide also gains a [Reconciliation](/api/webhooks#reconciliation) section: which field to read in place of each event you may have missed, and a sweep to run on a schedule and after an incident on your side. Nothing about delivery, retries, or any payload changed — this documents recovery paths that already existed.
When doola needs something from you before a company's formation can continue — most often because the state rejected every company name you submitted — it now raises a **required action** on the company instead of reaching out over Slack or email.
Two new webhook events announce one: `company_name_options_required` and `signature_ss4_reset`. Both carry a `requiredActionId`, an `actionCode`, and a `reason` written for your user to read. New endpoints let you list every open action across your companies in one call, read a single action with its full history, and submit new company name options to resolve it.
Nothing changes for a formation that never hits one, and no existing payload field changed. See [Required actions](/api/required-actions).
A new read-only endpoint, `GET /v1/partner/companies/{companyId}/compliance/calendar`, returns a company's tracked compliance obligations. Today it returns the **annual report** with its `nextDueDate`, `lastFiledDate`, and a derived `status` (`UPCOMING`, `OVERDUE`, `NOT_YET_DUE`, or `UNSUPPORTED_STATE`).
The obligation is always returned — for an unsupported state or a company that is too new, the `status` carries the reason and the dates are `null`. Reading the calendar never triggers a filing or recomputation. See [Compliance calendar](/api/compliance/calendar).
The [expedited EIN](/api/expedited-ein) no longer requires enablement on your account. Send `requestedServices: [{ "service": "EinCreation", "variant": "Expedite" }]` on create company endpoint to request it. Expedited processing remains an additional fee, billed per company formed with it.
Everything else is unchanged: expedited EIN is still available only for a **non-US founder** (`400 E_EXPEDITED_EIN_NOT_ELIGIBLE` otherwise, before anything is created), and the founder still signs a [Form 8821](/api/expedited-ein#the-form-8821-signature) alongside the SS-4.
Create company and Retrieve company now return `formationFilingNumber` — the filing number the state assigned when the formation was filed. It is `null` until the formation is filed and is populated alongside `formationFilingDate`; see [Reading progress](/api/formation#reading-progress).
In the [sandbox playground](/api/sandbox-playground), `formation/complete` sets a fixed test value (`PLAYGROUND-FILING`).
A new webhook event, `document_operatingagreement_uploaded`, fires when an **LLC** company's Operating Agreement is ready to download. doola now generates the Operating Agreement for partner companies shortly after the Articles of Organization are processed, so the event typically arrives soon after `document_aoo_uploaded`.
The payload is the standard `document_*` shape (`documentId`, `documentType: "OperatingAgreement"`, `documentCreatedAt`); use `documentId` with the [document endpoints](/api/domain-objects#document) to download. All partner tenants are subscribed automatically; see [Webhooks](/api/webhooks#events).
Previously doola did not generate an Operating Agreement for partner companies. The [sandbox playground](/api/sandbox-playground)'s `formation/complete` now also lists the event in `triggeredEvents` for an LLC test company.
A new webhook event, `document_corporatebylaws_uploaded`, fires when a **CCorp** company's Corporate Bylaws are ready to download. doola generates the Bylaws shortly after the Articles of Organization are processed, so the event typically arrives soon after `document_aoo_uploaded`.
The payload is the standard `document_*` shape (`documentId`, `documentType: "CorporateBylaws"`, `documentCreatedAt`); use `documentId` with the [document endpoints](/api/domain-objects#document) to download. All partner tenants are subscribed automatically; see [Webhooks](/api/webhooks#events).
Previously the Bylaws appeared in the documents list without any event. The [sandbox playground](/api/sandbox-playground)'s `formation/complete` now also lists the event in `triggeredEvents` for a CCorp test company.
Sandbox now includes a playground that drives a test company through the two milestones you cannot control in the real world: the state confirming the formation, and the IRS issuing the EIN.
* `POST /v1/partner/playground/companies/{companyId}/formation/complete` sets the filing date, uploads the Articles of Organization, fires `company_formation_completed` and `document_aoo_uploaded`, and advances the Formation service to `Completed`.
* `POST /v1/partner/playground/companies/{companyId}/eincreation/complete` sets a test EIN, uploads the EIN letter, fires `company_ein_issued` and `document_einletter_uploaded`, and advances the EinCreation service to `Completed`.
Each endpoint runs the same operations production uses, so webhooks are signed, subscription-filtered, retried, and logged exactly like real deliveries. The endpoints exist only in sandbox. See the new [Sandbox playground](/api/sandbox-playground) guide.
A non-US founder who requests an [expedited EIN](/api/expedited-ein) now signs a **Form 8821** (Tax Information Authorization) in addition to the SS-4, letting doola retrieve the EIN letter directly from the IRS. It uses the same signing flow as the SS-4.
* `signatureRequirements` on Create company and Retrieve company now includes a pending `FORM8821` entry alongside `SS4` for a non-US + expedited-EIN company. Standard-EIN and US companies are unchanged.
* `POST /v1/partner/companies/{companyId}/signatures` accepts `documentType: "FORM8821"`.
* Three new webhook events mirror the SS-4 set: `signature_form8821_completed`, `document_form8821_uploaded`, and `signature_form8821_reminder_due` (day 1, 3, 7).
See [Non-US founders](/api/non-us-formation). Existing SS-4 behavior is unchanged.
Partners with the expedited EIN feature can now request prioritized EIN processing for **non-US founders**. Add `requestedServices: [{ "service": "EinCreation", "variant": "Expedite" }]` to the create-company request; the company's `EinCreation` service then carries `variant: "Expedite"` on the create and retrieve responses.
Both gates run before anything is created: Expedited EIN must be part of your partner agreement (`403 E_FEATURE_NOT_ENABLED` otherwise) and the founder must be non-US (`400 E_EXPEDITED_EIN_NOT_ELIGIBLE` otherwise). A rejected request creates no company and releases your `Idempotency-Key`.
Omitting `requestedServices` keeps standard processing, so existing integrations are unaffected. See the new [Expedited EIN](/api/expedited-ein) guide.
A new webhook event, `company_ein_issued`, fires the first time a company's EIN is recorded. Fetch the company to read the `ein` field. A later correction to the EIN value does not re-fire it.
It complements `document_einletter_uploaded`, which fires separately once the IRS confirmation letter is downloadable. All partner tenants are subscribed automatically; see [Webhooks](/api/webhooks#events).
Create-company now accepts an `industry` field for precise NAICS classification. The response now echoes both `naicsCode` and `industry`.
**`naicsCode` is deprecated.** It keeps working through **2026-09-21** (90 days) so existing integrations are unaffected. When both fields are sent, `industry` takes precedence.
**Upgrade:** switch the create payload from `naicsCode` to `industry`, sourcing the value from the NAICS reference list. See [Choosing an industry](/api/naics-industry).
You can now form US companies for non-US founders through the Partner API. When no member, executive member, or responsible party has a US tax ID, the founder signs an SS-4 before the IRS issues the EIN.
* Create company and Retrieve company now return `signatureRequirements`: a pending `SS4` entry for a non-US founder, or an empty list for a US founder.
* `POST /v1/partner/companies/{companyId}/signatures` returns a fresh, whitelabeled signing link (expires in two hours) to embed or send to the founder.
* Three new webhook events: `signature_ss4_completed`, `document_ss4_uploaded`, and `signature_ss4_reminder_due` (day 1, 3, 7). You own the reminder cadence.
See the new [Non-US founders](/api/non-us-formation) guide. US-founder integrations are unaffected.
Don't use `formationSubmissionStatus` to check whether a formation is finished. This field only tracks whether doola has received the formation request. Its values are `PENDING`, `SUBMITTED`, and `FAILED`, and it never reaches a "completed" state, so polling it for completion will wait forever.
To know when a formation is actually done (filed with the state, EIN issued, documents available), read the per-service statuses on [Company services](/api/company-services), subscribe to the `company_formation_completed` webhook, or check the `ein` and document fields on the company. The [Company formation](/api/formation) guide now spells this out in its Status lifecycle section.
Creating a customer with an email that already belongs to a non-customer principal (for example a partner-dashboard user) now returns `409 E_EMAIL_IN_USE` instead of `500`. One email cannot be both a dashboard user and a customer, so this is a client-side conflict you can handle — use a different email.
A failed create no longer locks its `Idempotency-Key`. If a create returns a `4xx` (validation or pre-check) or a transient `5xx`, the key is released — fix the request and retry with the **same** key instead of generating a new one.
Successful creates are still replayed for the same key, and reusing a key from an already-successful create with a different body still returns `409 E_IDEMPOTENCY_KEY_REUSED`.
The create-company endpoint now supports C-Corp formation. Set `entityType` to `CCorp` and provide `executiveMembers` (President, Secretary, Treasurer, and Director) and `ccorpValuation` (authorized shares and par value per share) in place of `members`.
`entityType` defaults to `LLC`, so existing LLC integrations are unaffected.
First release of the doola Partner API developer docs and reference.
* **Customers** — create, retrieve, and list customers.
* **Companies** — submit a formation, retrieve a company, and list companies.
* **Documents** — list a company's documents and fetch a download URL.
* **Reference data** — countries, US states, NAICS codes, and state filing fees.
* Guides for authentication, the formation lifecycle, webhooks, and errors.
* Interactive API reference generated directly from the live API.
# Company services
Source: https://docs.doola.com/api/company-services
The services attached to every company, how they are structured, and how they progress through their lifecycle.
When a company is created, doola attaches three services to it automatically: **Formation**, **EinCreation**, and **RegisteredAgent**. Each service has its own status and subStatus that move independently as doola and third-party providers complete their work.
## Services
| Service | What it does |
| ----------------- | ------------------------------------------------------------------------------------ |
| `Formation` | Files the company with the state authority (e.g., Delaware Division of Corporations) |
| `EinCreation` | Obtains the Employer Identification Number from the IRS |
| `RegisteredAgent` | Provides the required registered agent address in the formation state |
## Service fields
Each service object on a company has the following fields:
| Field | Type | Description |
| ----------- | -------- | --------------------------------------------------------------------------------------------- |
| `name` | `string` | Service identifier — `Formation`, `EinCreation`, or `RegisteredAgent` |
| `variant` | `string` | Processing tier for the service (see [Variants](#variants)) |
| `status` | `string` | Lifecycle status (see [Statuses](#statuses)) |
| `subStatus` | `string` | More granular signal about what the service is waiting on (see [Sub-statuses](#sub-statuses)) |
## Variants
| Variant | Meaning |
| ---------- | ------------------------------------------------------- |
| `NONE` | Default — no special processing tier |
| `Standard` | Standard processing speed — applicable to `EinCreation` |
| `Expedite` | Expedited processing — applicable to `EinCreation` |
To request the `Expedite` variant for the EIN service at company creation, see [Expedited EIN](/api/expedited-ein).
## Statuses
| Status | Meaning |
| ------------ | ----------------------------------------------------------------------------- |
| `NotStarted` | Service has not begun — waiting on a dependency to complete first |
| `InProgress` | Service is actively being worked on by doola or a third party |
| `Completed` | Service finished successfully |
| `Active` | Service is live and active (used for ongoing services like `RegisteredAgent`) |
| `Canceled` | Service was canceled |
## Sub-statuses
Sub-statuses give a more precise signal about what a service is waiting on. They are informational — your integration does not need to act on them, but they are useful for debugging and support.
| Service | subStatus | Meaning |
| ----------------- | ----------------------------------- | ---------------------------------------------------------------------------- |
| `Formation` | `WaitingForRaToSucceed` | Formation filing is held until the registered agent record is confirmed |
| `EinCreation` | `WaitingForFilingServiceToComplete` | EIN application cannot proceed until the formation filing is complete |
| `RegisteredAgent` | `WaitingForFormation` | Registered agent activation is blocked until the formation service completes |
## Initial state
When a company is first created the services are seeded with the following values:
| Service | status | variant | subStatus |
| ----------------- | ------------ | ------- | ----------------------------------- |
| `Formation` | `InProgress` | `NONE` | `WaitingForRaToSucceed` |
| `EinCreation` | `NotStarted` | `NONE` | `WaitingForFilingServiceToComplete` |
| `RegisteredAgent` | `NotStarted` | `NONE` | `WaitingForFormation` |
If the company was created with the [expedited EIN](/api/expedited-ein), `EinCreation` is seeded with `variant: "Expedite"` instead of `NONE`.
## Reading service state
Services are returned as a list on the get-company response under the `services` field. Poll the endpoint or use [webhooks](/api/webhooks) to track progress — the `company_formation_completed` event fires when Formation reaches `Completed`, and `company_ein_issued` fires when the EIN is recorded on the company.
# Compliance Calendar
Source: https://docs.doola.com/api/compliance/calendar
Read a company's tracked compliance obligations and their due dates.
The compliance calendar is **read-only**. Reading it never triggers a filing, a recomputation, or any side effect — it reflects what doola already tracks for the company.
A company's compliance calendar lists the recurring obligations doola tracks to keep it in good standing. Today it returns a single obligation — the **annual report** — with its next due date and a derived status. The response is a list, so future obligation types slot in as additional entries without a breaking change.
## Retrieving the calendar
```
GET /v1/partner/companies/{companyId}/compliance/calendar
```
The full request and response schema is in the **Compliance** section of the API reference. A successful response:
```json theme={null}
{
"events": [
{
"type": "ANNUAL_REPORT",
"state": "CA",
"nextDueDate": "2026-11-30",
"lastFiledDate": "2025-11-15",
"status": "UPCOMING"
}
]
}
```
| Field | Meaning |
| --------------- | --------------------------------------------------------------------------------------------------------------------- |
| `type` | Obligation type. Only `ANNUAL_REPORT` is returned today. |
| `state` | US state the obligation applies to. |
| `nextDueDate` | Next due date (`yyyy-MM-dd`), or `null` when there is no concrete date — see the statuses below. |
| `lastFiledDate` | When the obligation was last filed (`yyyy-MM-dd`), or `null` if never filed. Explains a rolled-forward `nextDueDate`. |
| `status` | Derived status — see [Statuses](#statuses). |
## Statuses
`status` is derived at read time from the resolved due date and the company's state:
| Status | Meaning | Dates |
| ------------------- | ------------------------------------------------------------------------------- | ------------------------------- |
| `UPCOMING` | A due date is set today or in the future. | `nextDueDate` set |
| `OVERDUE` | The due date has passed and the report was not filed. | `nextDueDate` set (in the past) |
| `NOT_YET_DUE` | The company's formation filing is not yet complete, so no due date applies yet. | dates `null` |
| `UNSUPPORTED_STATE` | doola does not track an annual report for the company's state. | dates `null` |
The annual-report event is **always returned** — even for an unsupported state or a company that is too new. The `status` carries the reason and the date fields are `null`, so you never receive an empty list you have to disambiguate.
## Roll-forward
The annual report recurs each year. Once a report is filed, `nextDueDate` advances to the next occurrence and `lastFiledDate` records when the previous one was filed — so a future due date is always explainable to the customer.
## Errors
| HTTP | When |
| ------------------------- | -------------------------------------------------------------------- |
| `404 E_COMPANY_NOT_FOUND` | The company does not exist, or is not owned by your partner account. |
Ownership is enforced the same way across the Partner API: a company that exists but isn't yours returns `404`, never `403`, so existence cannot be probed across partners. See [Errors](/api/errors).
# Domain objects
Source: https://docs.doola.com/api/domain-objects
The shapes and business rules behind customer and company formation requests.
This page explains the objects and rules you assemble a request from. The API reference has the complete, generated field list and types; this page focuses on the relationships and validation rules that the schema alone does not spell out.
## Customer
| Field | Required | Notes |
| -------------------- | -------- | ----------------------------------------------------------------------- |
| `email` | Yes | Valid email address |
| `firstName` | Yes | 1 to 50 non blank characters |
| `lastName` | Yes | 1 to 50 non blank characters |
| `countryOfResidence` | Yes | ISO 3166-1 alpha-3 code, for example `USA`. See the countries reference |
| `phoneNumber` | No | Include the international code, for example `+12125550100` |
## Company formation
| Field | Required | Notes |
| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------ |
| `doolaCustomerId` | Yes | The id returned by Create customer |
| `entityType` | No | `LLC` (default) or `CCorp`. Determines which member and valuation fields apply |
| `state` | Yes | Two letter US state, for example `WY`. See the states reference |
| `industry` | Yes | An exact label from the NAICS reference. See [Choosing an industry](/api/naics-industry) |
| `description` | Yes | Business description, up to 50 characters |
| `nameOptions` | Yes | 1 to 3 ordered name preferences |
| `members` | Conditional | LLC only. Required for an LLC; ownership must total exactly 100. Omit for a CCorp |
| `executiveMembers` | Conditional | CCorp only. Required for a CCorp; omit for an LLC |
| `ccorpValuation` | Conditional | CCorp only. Required for a CCorp; omit for an LLC |
| `responsibleParty` | Yes | The person responsible for the EIN application |
| `addresses` | Yes | Exactly two: a mailing address and a business address |
| `requestedServices` | No | Per-service variant overrides. Only the EIN service is configurable; see [Expedited EIN](/api/expedited-ein) |
`entityType` defaults to `LLC`. A `CCorp` swaps `members` for `executiveMembers` and `ccorpValuation` (see below).
**US vs non-US founder is derived from the `ssn` field**, which holds a US tax ID (SSN or ITIN). If no `member`, `executiveMember`, or `responsibleParty` supplies one, doola treats the founder as non-US and the response carries a pending SS-4 in `signatureRequirements`. If anyone supplies an SSN or ITIN, `signatureRequirements` is empty. See [Non-US founders](/api/non-us-formation).
### Name option
Provide up to three in order of preference. doola uses the first name that clears the state's availability check. If the state rejects every one of them, doola raises a [required action](/api/required-actions/name-options) asking you for new options.
| Field | Required | Notes |
| ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Company name without the entity suffix |
| `entityTypeEnding` | Yes | Must match the entity type. LLC: `LLC`, `L.L.C`, `L.L.C.`, `Limited Liability Company`. CCorp: `Inc`, `Inc.`, `Incorporated`, `Corp.`, `Corporation`, `C Corp`, `C Corporation`, `CCorp`, `Company` |
### Company address
Submit one entry per role. There must be exactly two: a `mailing` address and a `business` address.
| Field | Required | Notes |
| ---------- | ----------- | ------------------------------------------------------------------------------------ |
| `provider` | Yes | `customer` (you supply the address) or `registeredAgent` (doola supplies it) |
| `type` | Yes | `mailing` or `business` |
| `address` | Conditional | Required when `provider` is `customer`. Omit it when `provider` is `registeredAgent` |
### Address
| Field | Required | Notes |
| ------------ | ----------- | ----------------------------------------------------------- |
| `line1` | Yes | Street address line 1 |
| `line2` | No | Optional |
| `city` | Yes | City |
| `state` | Yes | State, two letter abbreviation for the US |
| `postalCode` | Yes | Postal or ZIP code |
| `country` | Yes | ISO 3166-1 alpha-3 code, for example `USA` |
| `phone` | Conditional | Optional on a plain address, required on a member's address |
### Responsible party
The person responsible for the company's EIN application.
| Field | Required | Notes |
| ---------------- | -------- | ------------------------------ |
| `legalFirstName` | Yes | Legal first name |
| `legalLastName` | Yes | Legal last name |
| `address` | Yes | See Address |
| `ssn` | No | Optional. Format `XXX-XX-XXXX` |
| `email` | No | Optional, valid email |
### Member
The sum of `ownershipPercent` across all members must equal exactly 100.
| Field | Required | Notes |
| ------------------ | -------- | ----------------------------------------------- |
| `legalFirstName` | Yes | Legal first name |
| `legalLastName` | Yes | Legal last name |
| `contactFullName` | Yes | Full name as it should appear on filings |
| `isNaturalPerson` | Yes | `true` for an individual, `false` for an entity |
| `ownershipPercent` | Yes | Must total 100 across all members |
| `address` | Yes | See Address. `phone` is required here |
| `ssn` | No | Optional. Format `XXX-XX-XXXX` |
### Executive member (CCorp)
Supplied as `executiveMembers` and required for a `CCorp`. The list must include at least one President, Secretary, Treasurer, and Director. President, Secretary, and Treasurer may each appear only once; Director may repeat.
| Field | Required | Notes |
| ---------------- | -------- | -------------------------------------------------------- |
| `type` | Yes | One of `President`, `Secretary`, `Treasurer`, `Director` |
| `legalFirstName` | Yes | Legal first name |
| `legalLastName` | Yes | Legal last name |
| `address` | Yes | See Address |
| `ssn` | No | Optional. Format `XXX-XX-XXXX` |
### Share structure (CCorp)
Supplied as `ccorpValuation` and required for a `CCorp`.
| Field | Required | Notes |
| ------------ | -------- | ------------------------------------------- |
| `noOfShares` | Yes | Number of authorized shares. Greater than 0 |
| `shareValue` | Yes | Par value per share in USD. Greater than 0 |
### Requested service
Supplied as `requestedServices`, optional. Requests a specific variant for one of the company's services; today only the expedited EIN, available for non-US founders. See [Expedited EIN](/api/expedited-ein) for availability and errors.
| Field | Required | Notes |
| --------- | -------- | --------------------------------------------------------------------------- |
| `service` | Yes | Only `EinCreation` is supported |
| `variant` | Yes | `Standard` or `Expedite`. `Standard` behaves the same as omitting the entry |
## Document
Returned by the document endpoints. `downloadUrl` is present only on the single document endpoint and is a temporary signed URL valid for about one hour.
| Field | Notes |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Document id |
| `companyId` | Parent company id |
| `name` | File name |
| `contentType` | MIME type, for example `application/pdf` |
| `documentType` | Common values: `ArticlesOfOrganization`, `EinLetter`, `Mail`, `CorporateBylaws` (CCorp), `OperatingAgreement` (LLC), `SignedSS4` (non-US founders), and `SignedForm8821` (non-US founders on an expedited EIN). Not a closed set — other document types can appear over a company's lifetime |
| `createdAt` | Upload timestamp |
| `lastModified` | Last modification timestamp |
| `downloadUrl` | Temporary signed download URL (single document endpoint only) |
## Signature requirement
Returned on the company as `signatureRequirements` by both Create company and Retrieve company. The array is always present: an `SS4` entry for a non-US founder (plus a `FORM8821` entry when that founder is on an expedited EIN), or empty for a US founder. A `FORM8821` entry also appears, for any founder, once doola has [asked for one](/api/required-actions/form8821-required). See [Non-US founders](/api/non-us-formation).
| Field | Values | Notes |
| -------------- | ---------------------- | --------------------------------------------------------- |
| `documentType` | `SS4`, `FORM8821` | The document the founder must sign |
| `status` | `PENDING`, `COMPLETED` | `PENDING` until that document is signed, then `COMPLETED` |
## Required action
Something doola needs from you before a company's formation can continue. Announced by webhook and readable per company or across your whole tenant. See [Required actions](/api/required-actions).
| Field | Values | Notes |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `requiredActionId` | | Identifier for the action. Key your records on it, and read the action against it |
| `doolaCompanyId` | | The company the action is on |
| `actionCode` | `FORMATION_NAME_OPTIONS_EXHAUSTED`, `FORMATION_SIGNATURE_SS4_RESET`, `FORMATION_SIGNATURE_FORM8821_REQUIRED` | What is needed. Branch on this, never on `reason` |
| `actionName` | | Short human-readable label for the action |
| `status` | `created`, `delivered`, `delivery_failed`, `submitted`, `resolved`, `rejected` | The last thing that happened to the action |
| `reason` | | Explanation written for your user to read |
| `open` | `true`, `false` | `false` once `resolved` or `rejected`; a closed action never reopens |
| `updatedAt` | | When the action last changed |
| `history` | | Every step, oldest first. Returned by the single-action read and by the resolution endpoint; omitted from both list responses |
## Signature session
Returned by the create-signature-session endpoint. Holds the short lived, whitelabeled signing link to present to the founder.
| Field | Notes |
| -------------- | ------------------------------------------------------------------------------------- |
| `id` | Identifier for the signing session |
| `url` | doola signing link to embed or share. Expires at `expiresAt` |
| `documentType` | `SS4`, `FORM8821` |
| `expiresAt` | When `url` stops working (two hours after issuance). Request a new session after this |
# Errors
Source: https://docs.doola.com/api/errors
The error envelope and the global error codes returned by the doola Partner API.
## Response shape
Every response uses the same envelope. On success, `payload` holds the result and `error` is `null`.
```json theme={null}
{
"payload": { },
"error": null
}
```
On failure, `payload` is `null` and `error` carries a machine readable `code`, a human readable `message`, a `requestId`, and, for validation failures, per field details.
```json theme={null}
{
"payload": null,
"error": {
"code": "E_VALIDATION_FAILED",
"message": "one or more fields are invalid",
"requestId": "3D2mMaLHsT60sI5qyeHuBlS9TEM",
"fields": {
"doolaCustomerId": {
"code": "E_CUSTOMER_ID_REQUIRED",
"message": "customer id is required"
}
}
}
}
```
Branch on `error.code`, not on `message`. Codes are stable; messages may change.
## Global error reference
These can be returned by any endpoint. Endpoint specific codes are documented in the API reference.
| HTTP | Code | Meaning |
| ---- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_IDEMPOTENCY_KEY_REQUIRED` | Missing `Idempotency-Key` header |
| 400 | `E_IDEMPOTENCY_KEY_TOO_LONG` | `Idempotency-Key` exceeds the max length |
| 400 | `E_MALFORMED_JSON` | Request body is not parseable JSON |
| 400 | `E_REQUEST_BODY_INVALID` | Body is missing, malformed, or the wrong shape |
| 400 | `E_PAGINATION_INVALID` | Field detail on a list endpoint when `page` is negative or `size` is below 1. An oversized `size` is capped at 100 rather than rejected |
| 401 | `E_AUTH_MISSING` | No `Authorization` header present |
| 401 | `E_AUTH_INVALID` | Invalid or expired key, or a key used on the wrong environment |
| 403 | `E_TENANT_SUSPENDED` | Partner tenant is suspended; writes are rejected |
| 405 | `E_METHOD_NOT_ALLOWED` | HTTP method not allowed for this resource |
| 409 | `E_IDEMPOTENCY_IN_PROGRESS` | Same idempotency key is still processing |
| 409 | `E_IDEMPOTENCY_KEY_REUSED` | Same idempotency key used with a different body |
| 409 | `E_EMAIL_IN_USE` | Email already belongs to a non-customer principal (e.g. a partner dashboard user); one email cannot be both |
| 429 | `E_RATE_LIMITED` | Too many requests; the rate limit was exceeded. Back off and retry after the `Retry-After` response header (seconds) |
| 500 | `E_INTERNAL` | Unexpected server error |
| 503 | `E_SEARCH_UNAVAILABLE` | The search backend behind the list endpoints is unavailable. Retry with backoff — this signals a doola-side outage, never bad input (invalid pagination is a 400, see `E_PAGINATION_INVALID`) |
## Industry errors
Returned only by Create company. The [Choosing an industry](/api/naics-industry#errors) guide has the full context.
| HTTP | Code | Meaning |
| ---- | --------------------- | ------------------------------------------------------------------------------------------------------- |
| 400 | `E_VALIDATION_FAILED` | `industry` missing, blank, or not in the NAICS reference; the field detail carries `E_INDUSTRY_INVALID` |
## Expedited EIN errors
Returned only by Create company when `requestedServices` is present. The [Expedited EIN](/api/expedited-ein#errors) guide has the full context.
| HTTP | Code | Meaning |
| ---- | ------------------------------ | ------------------------------------------------------------------------------------------ |
| 400 | `E_EXPEDITED_EIN_NOT_ELIGIBLE` | Someone on the company supplies a US tax ID; expedited EIN is non-US only |
| 400 | `E_VALIDATION_FAILED` | Invalid `requestedServices` shape; the field detail carries `E_REQUESTED_SERVICES_INVALID` |
## Required action errors
Returned by the required-action reads and the resolution endpoint. The [Required actions](/api/required-actions#errors) guide has the full context.
| HTTP | Code | Meaning |
| ---- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_VALIDATION_FAILED` | `actionCode` missing, or `nameOptions` missing, empty, more than three, or carrying an unrecognised `entityTypeEnding`; the field detail names the rule |
| 400 | `E_REQUIRED_ACTION_CODE_MISMATCH` | `actionCode` does not match the action being resolved |
| 400 | `E_NAME_OPTIONS_INVALID` | A recognised ending that does not belong to the company's entity type |
| 404 | `E_REQUIRED_ACTION_NOT_FOUND` | No action with this ID on this company, including one that belongs to another partner |
| 404 | `E_COMPANY_NOT_FOUND` | No company with this ID belongs to your tenant |
| 409 | `E_REQUIRED_ACTION_CLOSED` | The action is already resolved or rejected and never reopens |
| 422 | `E_REQUIRED_ACTION_NOT_RESOLVABLE` | The action is resolved by another flow, such as an SS-4 re-signature |
## Signature errors
Returned only by the non-US founder signing endpoints. The [Non-US founders](/api/non-us-formation#errors) guide has the full context.
| HTTP | Code | Meaning |
| ---- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_DOCUMENT_TYPE_NOT_SUPPORTED` | The document type cannot be signed through this endpoint |
| 404 | `E_NOT_FOUND` | No company with this ID belongs to your tenant |
| 409 | `E_DOCUMENT_ALREADY_SIGNED` | The requested document (SS-4 or Form 8821) is already signed |
| 410 | `E_SIGNATURE_URL_EXPIRED` | Signing link expired, voided, or tampered with; request a new session |
| 422 | `E_FORMATION_NOT_ELIGIBLE` | The company does not require this document (e.g. an SS-4 for a US company, or a Form 8821 without an expedited EIN and without an open [Form 8821 request](/api/required-actions/form8821-required)) |
# Expedited EIN
Source: https://docs.doola.com/api/expedited-ein
Request prioritized EIN processing at company creation for non-US founders.
By default, the EIN application joins the standard IRS processing queue. Expedited processing is available for an additional fee, billed per use.
You request expedited processing in the same create-company call: one extra field, no separate endpoint. The founder also signs one additional document — [Form 8821](#the-form-8821-signature) — alongside the SS-4. Everything else about the [formation flow](/api/formation) is unchanged.
## Availability
Expedited EIN has one requirement: **the founder must be non-US.** Expedited processing is only available when no `member`, `executiveMember`, or `responsibleParty` supplies a US tax ID (SSN or ITIN). This is the same determination that produces the SS-4 requirement for [non-US founders](/api/non-us-formation).
A request that fails that check is rejected in full: **no company is created**, and the rejection is a `4xx`, so your `Idempotency-Key` is released. Fix the request and retry with the same key.
## Requesting the expedited EIN
Add `requestedServices` to the [create-company](/api/api-reference/companies/create-a-company) request body:
```json theme={null}
{
"doolaCustomerId": "31pLCw7sN2aQ9fJkRtY3vXbHmLe",
"state": "WY",
"...": "...",
"requestedServices": [
{ "service": "EinCreation", "variant": "Expedite" }
]
}
```
| Field | Values | Meaning |
| --------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `service` | `EinCreation` | The service to configure. Only the EIN service is configurable today. |
| `variant` | `Standard`, `Expedite` | `Expedite` requests prioritized EIN processing. `Standard` behaves exactly the same as omitting the entry. |
Omitting `requestedServices` keeps standard processing, so existing integrations are unaffected.
## Reading it back
The company's `EinCreation` service carries the variant, on both the create response and [Get a company](/api/api-reference/companies/get-a-company):
```json theme={null}
{
"services": [
{ "name": "Formation", "status": "InProgress", "variant": "NONE" },
{ "name": "EinCreation", "status": "NotStarted", "variant": "Expedite" },
{ "name": "RegisteredAgent", "status": "NotStarted", "variant": "NONE" }
]
}
```
See [Company services](/api/company-services) for the full service lifecycle.
## The Form 8821 signature
Expedited processing relies on one extra authorization from the founder: **Form 8821** (Tax Information Authorization), which lets doola retrieve the EIN letter directly from the IRS. An expedited company therefore carries a second signature requirement alongside the SS-4, on both the create response and [Get a company](/api/api-reference/companies/get-a-company):
```json theme={null}
{
"signatureRequirements": [
{ "documentType": "SS4", "status": "PENDING" },
{ "documentType": "FORM8821", "status": "PENDING" }
]
}
```
Collect it exactly like the SS-4: request a signing session with `documentType: "FORM8821"` and present the whitelabeled link to the founder. The [signing flow](/api/non-us-formation#the-signing-flow) is identical, and the two documents are independent — collect them in parallel or in any order.
Three webhook events mirror the SS-4 set: `signature_form8821_completed` fires the instant the founder signs, `document_form8821_uploaded` once the signed PDF is stored and downloadable, and `signature_form8821_reminder_due` (day 1, 3, 7) while the document remains unsigned. See [Webhooks](/api/webhooks#events).
doola cannot retrieve the EIN letter from the IRS until the Form 8821 is signed, so an unsigned Form 8821 holds up the expedited path just like an unsigned SS-4 holds up the EIN application itself.
## What stays the same
Expedited EIN changes how fast the EIN application is processed, not the flow around it:
* **The SS-4 signature is still required.** Every expedited company has a non-US founder, so the founder signs an SS-4 before the IRS issues the EIN, exactly as described in [Non-US founders](/api/non-us-formation).
* **Formation itself is not expedited.** State filing and registered agent proceed on their usual timelines; the EIN application is prioritized once it can proceed.
* **The same signals report progress.** Track the `EinCreation` service status, the `ein` field on the company, and the webhooks below.
## Knowing when the EIN is issued
Two webhooks signal EIN issuance, at two distinct moments. They apply to every formation, expedited or not:
| Event | Fires when |
| ----------------------------- | ------------------------------------------------------------------------------------------------ |
| `company_ein_issued` | doola records the company's EIN. Fetch the company to read the `ein` field. |
| `document_einletter_uploaded` | The IRS EIN confirmation letter is stored and ready to [download](/api/domain-objects#document). |
See [Webhooks](/api/webhooks) for payloads and delivery details.
## Errors
Branch on `error.code`, never on the message.
| HTTP | Code | Meaning |
| ---- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_EXPEDITED_EIN_NOT_ELIGIBLE` | Someone on the company supplies a US tax ID, so it does not qualify for expedited processing. Retry without `requestedServices` for standard processing. |
| 400 | `E_VALIDATION_FAILED` | The `requestedServices` shape is invalid (an unsupported `service` or `variant`). The field detail carries `E_REQUESTED_SERVICES_INVALID` with the concrete reason. |
The shape error looks like this:
```json theme={null}
{
"payload": null,
"error": {
"code": "E_VALIDATION_FAILED",
"message": "one or more fields are invalid",
"requestId": "3D2mMaLHsT60sI5qyeHuBlS9TEM",
"fields": {
"requestedServices": {
"code": "E_REQUESTED_SERVICES_INVALID",
"message": "variant must be one of [Standard, Expedite]; got 'Fast'"
}
}
}
}
```
See [Errors](/api/errors) for the response envelope and the global codes any endpoint can return.
# FAQ
Source: https://docs.doola.com/api/faq
Common questions about the doola Partner API and Partner Portal, in plain language.
Common questions about the doola Partner API and Partner Portal. For endpoint-level request and response detail, see the **API reference** tab and the [guides](/api/formation).
Don't have access yet? [Get API access](https://www.doola.com/partner-with-us/). Already a partner and can't find your answer here? Email [engineering@doola.com](mailto:engineering@doola.com).
## What you can do today
* **Form US companies (LLC or C-Corp) for your users.** Submit a formation request via the API; doola handles state filing, registered agent, and EIN. Your user never has to interact with doola directly.
* **Register and manage your customers.** Create customers (idempotent) and attach one or more companies to each.
* **Track formation status in real time.** Poll the API or receive webhook events as a company moves through formation.
* **Download company documents.** Articles of Organization, EIN letter, and forwarded mail, via temporary signed links.
* **Self-manage your API keys and webhook endpoint** in the Partner Portal, including reviewing delivery logs.
* **Browse everything in the Partner Portal**, a ready-made web dashboard for your team.
## Access and authentication
[Request API access](https://www.doola.com/partner-with-us/) and the doola team provisions your account, issues your first API key (`dk_live_...`), and creates Partner Portal logins for anyone on your team who needs dashboard access. You then generate your own [webhook signing secret](/api/webhooks#setup) in the portal.
Once your agreement is signed, you get sandbox access and production keys, a direct line to our engineering team for integration and API questions, and implementation support from sandbox through go-live and beyond.
API keys follow the format `dk_live_...`. They are long, unique, and scoped to your account only, so your key can never see another partner's data. Keys don't expire by default, and if one is ever compromised it can be revoked and replaced without losing any of your customer or company data.
Yes. The Partner Portal uses a standard email and password login. A JWT sign-in flow is also available if you want to build your own UI on top of the API.
**API key vs. Partner Portal:** the API key (`dk_live_...`) is for machine-to-machine calls such as creating customers, forming companies, and reading status. Anything else, such as managing API keys, setting your webhook URL, or reviewing delivery logs, lives in the Partner Portal.
Yes. Manage your API keys (create, list, revoke, delete) in the Partner Portal. Two intentional guardrails: an existing API key can't mint new keys (only a signed-in user can), and "revoke all keys at once" stays a doola-assisted action to avoid accidental lockouts.
Yes. Each person gets their own email and password login, and all logins see the same company and customer data scoped to your account.
Completely. Your account is a fully isolated tenant, so your API key only ever has access to companies and customers that belong to you. There is no way to see, or be seen by, another partner through the API or the portal.
## Forming companies
Every formation includes:
* **Entity filing** — the LLC or C-Corp filed with the state authority
* **EIN** (Employer Identification Number) from the IRS
* **Registered Agent service for the first year**, in the formation state
* **Governance document** — an Operating Agreement for an LLC, Corporate Bylaws for a C-Corp
* **US business address** — the Registered Agent address doubles as a US mailing address. Incoming mail is opened, scanned, and delivered to you through the `document_mail_uploaded` webhook, within these limits:
* All State and government notices are included, with no cap
* Plus the first 5 pieces of non-official mail
* **No packages** — the address accepts letter mail only
* Physical mail forwarding is available for an additional fee
The first three arrive as the three services attached to every company (`Formation`, `EinCreation`, `RegisteredAgent`), so you can track each one independently — see [Company services](/api/company-services). The governance document lands as a `document_operatingagreement_uploaded` or `document_corporatebylaws_uploaded` [webhook](/api/webhooks#events) shortly after the Articles of Organization are processed.
The only per-company charge on the base formation is the state filing fee, which varies by state and entity type — look it up from the [state-fees reference endpoint](/api/api-reference/reference-data/list-state-filing-fees).
**LLC and C-Corp.** Set the optional `entityType` field on the create-company request to `LLC` (the default when omitted) or `CCorp`. The two differ in who you list: an `LLC` takes `members` with ownership percentages, while a `CCorp` takes `executiveMembers` (officers and directors) and a `ccorpValuation` (authorized shares and par value per share).
* The customer the company belongs to (`doolaCustomerId`)
* The entity type, `LLC` (default) or `CCorp`
* The US state to form in
* Up to 3 preferred company names, in order. doola uses the first one that clears the state's availability check
* The industry, an exact label from the NAICS list (see [Choosing an industry](/api/naics-industry))
* A business description
* The responsible party (the person legally responsible for the EIN application)
* For an **LLC**: company members and their ownership percentages (must total exactly 100%)
* For a **C-Corp**: executive members (at least one President, Secretary, Treasurer, and Director; President, Secretary, and Treasurer may each appear once, Director may repeat) plus the share structure (number of authorized shares and par value per share)
doola auto-assigns the registered agent based on the chosen state, so you don't supply that.
Yes. doola forms US companies for founders anywhere in the world. When no one on the company has a US tax ID (SSN or ITIN), the IRS requires the founder to sign an **SS-4** before it issues the EIN. The create-company response flags this with a pending `signatureRequirements` entry, and you collect the signature through a fully whitelabeled doola signing link. See [Non-US founders](/api/non-us-formation) for the end-to-end flow.
Yes, for **non-US founders** (no member, executive member, or responsible party has an SSN or ITIN). Expedited processing is available for an additional fee, billed per use. Contact our team for pricing details.
Request it at company creation by adding `requestedServices: [{ "service": "EinCreation", "variant": "Expedite" }]` to the create-company payload. See [Expedited EIN](/api/expedited-ein) for the full guide.
All 50 US states plus Washington DC. Query `/v1/partner/references/states` for the live list, so you never hardcode anything in your onboarding forms.
For current state filing fees by state and entity type, see the [doola state fees spreadsheet](https://docs.google.com/spreadsheets/d/1BY5znyrHUcKGj8agkh_mxKyG8tRFegBsvZLGOmJy9w0/edit?usp=sharing).
Your API call returns immediately (usually within a second) with a `PENDING` status. The actual formation work happens asynchronously in the background. Timelines vary by state and current volume. You are notified by webhook the moment the status changes, and you can check status any time via the API or the portal.
The end-to-end journey your customer experiences, from submission to a ready-to-use company, typically runs as follows. Share this timeline with your clients to set expectations.
You collect the company details (name options, entity type, state) and member / responsible-party details, then submit them via the API. The call returns immediately with a `PENDING` status.
doola files the formation, prepares the Articles of Organization, and assigns the Registered Agent. Timing varies by state and current volume.
The EIN and the essential business documents needed for banking, hiring, and taxes are issued.
* **US founder:** 1–2 business days
* **Non-US founder:** 4–6 weeks on average
Non-US founders also sign an SS-4 as part of this step — see [Non-US founders](/api/non-us-formation).
Once the business documents are ready, your customer can apply for a US bank account through the partner portal. International clients need a passport.
The company is fully set up and compliant. Optional bookkeeping and tax services support ongoing operations.
These are typical estimates, not guarantees. State filing time varies by state and current volume, and EIN issuance follows the IRS's own timeline. The last two steps (bank account, ongoing operations) sit outside the Partner API and are shown only so you can share the full client journey.
Yes. Develop and test against `https://api.test.doola.com` before going to production (`https://api.doola.com`). Sandbox keys never trigger real state filings or charges. Sandbox also includes a [playground](/api/sandbox-playground) that triggers the formation and EIN webhooks on demand.
Yes. A mailing address is included as part of the registered agent service. Incoming mail is opened, scanned, uploaded, and delivered to you through the `document_mail_uploaded` webhook — all State and government notices are included, plus the first 5 pieces of non-official mail. The address accepts letter mail only, so **no packages**. Physical mail forwarding, when you need it, carries a separate fee.
When you form a company through the API, each company address entry (`mailing` and `business`) takes a `provider`: `registeredAgent` (doola supplies its registered agent address for the formation state) or `customer` (you supply your user's own address); see [Addresses](/api/formation#addresses). **We recommend `registeredAgent` for both.**
If you list the customer's own address instead, all official state correspondence, compliance notices, and the IRS EIN letter are sent directly to that address rather than to doola. doola then has no visibility into the entity's compliance status and cannot notify you of upcoming filings, state notices, or issues with formation. If the address is a home address, it also becomes part of the public record on state government websites.
Using doola's registered agent address ensures all compliance documents are received and tracked (and delivered to you through the `document_mail_uploaded` webhook), and allows doola to support you and your customer if issues arise during or after formation. It is included in the standard service at no extra charge.
The formation charge applies at submission, so you are charged when the API request is made. doola then processes the formation and checks name availability. If all of your submitted names are unavailable, doola raises a [required action](/api/required-actions/name-options) on the company and sends you a `company_name_options_required` webhook, whose `reason` usually names the rejected names. Collect new names from your user and submit them through the API; the formation resumes from there, and the charge stays in place throughout.
Fewer than 5% of formations hit a name conflict, and name-availability checking is coming to the API. Until then, submit all three name options to minimize the chance of a conflict.
doola follows up directly with webhook reminders ahead of the renewal date, so you have time to act before it lapses.
## Tracking status and documents
There are two separate signals, and they answer different questions:
| You want to know | Where to look |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Did doola receive my formation request? | `formationSubmissionStatus` on the company (`PENDING` → `SUBMITTED`, or `FAILED`) |
| Is the company actually formed with the state? | The **Formation** service status under [Company services](/api/company-services) (`InProgress` → `Completed`) |
`formationSubmissionStatus` only tracks the request handoff and never reaches a "completed" value, so don't poll it to detect a formed company. Watch the `company_formation_completed` webhook or the Formation service status instead. The `adminNotes` field carries any flags from the doola operations team (for example filing delays); check it first if something is taking longer than expected.
If doola's system can't complete the formation (for example a state filing issue), it retries automatically up to **4 times, at 5, 15, 30, and 60 minutes**, before marking the company `FAILED`. When that happens:
* The company status is set to `FAILED`
* You receive a `company_formation_failed` webhook
* The doola team is alerted internally
From there, reach your doola account contact to resolve it.
Subscribe to the `company_ein_issued` webhook, then fetch the company to read the `ein` field. The IRS confirmation letter follows separately via `document_einletter_uploaded` once it is ready to download. The two events arrive independently, so don't assume an order.
After a `document_*` webhook fires, call the documents endpoints to list a company's documents and fetch a temporary signed download URL (valid about 1 hour). Don't cache the URL; request a fresh one each time you need to download.
## Webhooks
Webhooks are HTTP notifications doola sends to your server whenever something meaningful happens, so you don't have to keep polling "is it done yet?". doola sends a `POST` with a JSON payload to your HTTPS endpoint, and your server responds `200 OK`.
Use the [Sandbox playground](/api/sandbox-playground). Two sandbox-only endpoints complete a test company's formation and EIN issuance on demand, firing `company_formation_completed`, `document_aoo_uploaded`, `company_ein_issued`, and `document_einletter_uploaded` through the normal delivery pipeline: signed, subscription-filtered, and logged in the Partner Portal.
| Event | Fires when |
| -------------------------------------- | ----------------------------------------------------------------------------- |
| `company_formation_submitted` | Formation accepted and processing started |
| `company_formation_completed` | Formation complete, company is active |
| `company_formation_failed` | Formation failed after all retries |
| `company_filing_date_updated` | The filing date was updated by doola |
| `company_ein_issued` | The company's EIN was issued and is readable on the company |
| `document_aoo_uploaded` | Articles of Organization ready to download |
| `document_einletter_uploaded` | EIN letter ready to download |
| `document_mail_uploaded` | Business mail document uploaded |
| `document_corporatebylaws_uploaded` | Corporate Bylaws ready to download (CCorp only) |
| `document_operatingagreement_uploaded` | Operating Agreement ready to download (LLC only) |
| `signature_ss4_completed` | A non-US founder signed their SS-4 |
| `document_ss4_uploaded` | Signed SS-4 stored and ready to download |
| `signature_ss4_reminder_due` | An unsigned SS-4 is due a reminder (day 1, 3, 7) |
| `signature_form8821_completed` | A non-US founder on an expedited EIN signed their Form 8821 |
| `document_form8821_uploaded` | Signed Form 8821 stored and ready to download |
| `signature_form8821_reminder_due` | An unsigned Form 8821 is due a reminder (day 1, 3, 7) |
| `company_name_options_required` | The state rejected every company name; new options are needed from you |
| `signature_ss4_reset` | A replacement name was accepted, voiding an SS-4 that had already been signed |
| `signature_form8821_required` | doola needs the founder to sign a Form 8821 so it can obtain the EIN |
| `partner_webhook_disabled` | Your webhook endpoint was automatically disabled (see below) |
The four SS-4 events fire only for non-US founders. The three Form 8821 signing events fire for non-US founders who also requested an expedited EIN, and for any founder doola has asked to sign one through a [Form 8821 request](/api/required-actions/form8821-required). See [Non-US founders](/api/non-us-formation).
If a delivery fails, doola retries up to **5 times, at 1 minute, 15 minutes, 1 hour, 12 hours, and 24 hours**.
After all retries are exhausted, doola automatically disables your webhook endpoint and sends a final `partner_webhook_disabled` event. Once you have fixed your endpoint, re-enable delivery in the Partner Portal.
Yes, both. **Settings → Webhook URL** in the Partner Portal holds your endpoint, its delivery status, and your signing secret. Generate or rotate the secret there; it is shown once, and a rotation invalidates the previous secret immediately, so treat it as a cutover for your signature verification and deploy the new value promptly. Generating a secret requires an active API key on the tenant.
Nothing is lost. Every state a webhook announces is also readable from the API, so you can always catch up by fetching the company and the open required-actions list. [Reconciliation](/api/webhooks#reconciliation) maps each event to the field that carries the same information, and outlines a sweep to run on a schedule and after any incident on your side.
Yes. Every delivery attempt is visible in the Partner Portal (timestamp, event name, HTTP status, attempt number).
Each delivery is signed with an `x-doola-signature` header, an HMAC-SHA256 hex digest of the raw request body, keyed with your webhook signing secret. Recompute it on your side and compare using a constant-time comparison (for example `timingSafeEqual` or `compare_digest`), never plain string equality.
In rare cases you may receive a duplicate `document_*` event for the same document (for example when ops re-issues a document after an IRS correction). Handle document events idempotently using `documentId`.
## Partner Portal
The Partner Portal (`partners-portal.doola.com`) is a ready-to-use web dashboard for your team, with no technical setup, just log in. It shows a searchable list of all your companies and customers, full company details (status, EIN, filing date, state, entity type, members, addresses), a Formation / EIN / Registered Agent status stepper, downloadable documents, and any notes left by the doola team. It is also where you self-manage your API keys, webhook URL, and signing secret, and review delivery logs.
Company and customer data is read-only in the portal. To create or change those, use the API.
The portal currently shows doola branding. White-labeling is not a standard offering today, but can be discussed for enterprise partners case by case. If you want a fully custom experience, build your own UI on top of the API.
Not directly. doola does not host a customer-facing interface for partner-created companies. You build whatever your end-users see, pulling current status from the API and displaying it in your own product.
## Data and privacy
doola stores the registration info you submit (email, first name, last name, country of residence, and optionally phone number) plus the company formation details.
SSNs, when submitted as part of responsible-party or member information, are **masked in all API responses**. Only a redacted version is ever returned (for example `****-**-1234`). The raw value is stored securely and used only for formation.
## Quick reference: available vs not
| Capability | Available? |
| --------------------------------------------------- | ----------------------------------------------------- |
| Form LLCs via API | ✅ Yes |
| Form C-Corps via API | ✅ Yes |
| Expedited EIN | ✅ Yes (non-US founders; billed per usage) |
| Register and list or search customers and companies | ✅ Yes |
| Download company documents | ✅ Yes |
| Real-time webhooks on key events | ✅ Yes |
| Self-service API key management | ✅ Yes |
| Self-service webhook configuration | ✅ Yes |
| Webhook delivery logs | ✅ Yes |
| Sandbox environment | ✅ Yes |
| Trigger formation and EIN completion in sandbox | ✅ Yes ([sandbox playground](/api/sandbox-playground)) |
| Partner Portal | ✅ Yes |
| Multiple team members on the portal | ✅ Yes |
| Name-availability check via API | ❌ Coming soon (submit 3 name options) |
| Self-serve initial signup | ❌ doola provisions your account |
| Edit companies or customers through the portal | ❌ Read-only, use the API |
| Customer-facing portal | ❌ You build your own |
| White-label portal | ❌ Not standard (enterprise case by case) |
## Related docs
* [Company formation](/api/formation): entity types, required fields, status lifecycle
* [Webhooks](/api/webhooks): setup, events, signature verification
* [Sandbox playground](/api/sandbox-playground): complete formation and EIN issuance on demand in sandbox
* [Errors](/api/errors): error codes and handling
* [Domain objects](/api/domain-objects): field-level reference
# Company formation
Source: https://docs.doola.com/api/formation
The formation flow, the statuses a company moves through, and where to read progress.
A formation is created in one request, then progresses asynchronously. You submit everything up front; doola validates it, files with the state, and obtains the EIN. You follow along with [webhooks](/api/webhooks) and by reading the company.
## Formation flow
```mermaid theme={null}
sequenceDiagram
participant P as Your backend
participant D as doola API
participant TP as ThirdParty (State, IRS)
P->>D: Create company
D-->>P: formationSubmissionStatus: PENDING
Formation: InProgress
EinCreation: NotStarted
D->>D: doola processes formation submission
D-->>P: webhook company_formation_submitted
D-->>P: formationSubmissionStatus: SUBMITTED
Formation: InProgress
EinCreation: NotStarted
TP-->>D: State confirms formation
D-->>P: webhook company_formation_completed
D-->>P: webhook document_aoo_uploaded
D-->>P: formationSubmissionStatus: SUBMITTED
Formation: Completed
EinCreation: InProgress
TP-->>D: IRS issues EIN
D-->>P: webhook company_ein_issued
D-->>P: webhook document_einletter_uploaded
D-->>P: formationSubmissionStatus: SUBMITTED
Formation: Completed
EinCreation: Completed
```
The diagram shows the typical order, not a strict dependency. `company_ein_issued` and `document_einletter_uploaded` fire when the IRS issues the EIN, on the IRS's own timeline, so they are independent of the formation-completion webhooks (`company_formation_submitted` and `company_formation_completed`) and can arrive well after them.
In sandbox you do not have to wait for the state or the IRS. The [Sandbox playground](/api/sandbox-playground) completes each milestone on demand and fires the webhooks above through the normal delivery pipeline.
React to changes via [webhooks](/api/webhooks). To display current progress, read each service's `status` on the company. The two signals this involves — submission status and the Formation service status — are explained under [Two things to track](#two-things-to-track) below.
## Entity types
Set `entityType` on the create-company request to choose what to form. It defaults to `LLC` when omitted.
| Entity type | Provide | Omit |
| --------------- | ------------------------------------ | ------------------------------------ |
| `LLC` (default) | `members` (ownership must total 100) | `executiveMembers`, `ccorpValuation` |
| `CCorp` | `executiveMembers`, `ccorpValuation` | `members` |
For a `CCorp`, the executive team must include at least one **President**, **Secretary**, **Treasurer**, and **Director** — President, Secretary, and Treasurer may each appear only once, while Director may repeat. Each name option's suffix must also match the entity type: an LLC ending (`LLC`, `Limited Liability Company`, …) for an LLC, or a corporate ending (`Inc.`, `Corporation`, …) for a CCorp.
See [Domain objects](/api/domain-objects) for the full field list of executive members and share structure.
## Addresses
Every create-company request carries exactly **two** address entries: one `mailing` and one `business`. For each entry you choose a `provider` that decides who supplies the address.
| `provider` | You send `address`? | What doola uses |
| ----------------- | ---------------------------------------------- | -------------------------------------------------------- |
| `customer` | Yes, required | The address you provide |
| `registeredAgent` | No. Omit it (any address you send is not used) | doola's Registered Agent address for the company's state |
So you can supply your own address, hand the entry to doola's Registered Agent, or mix the two (for example, your own `mailing` address with a `registeredAgent` `business` address):
```json theme={null}
{
"addresses": [
{ "provider": "customer", "type": "mailing", "address": { "line1": "251 W 30th St", "city": "New York", "state": "NY", "postalCode": "10001", "country": "USA" } },
{ "provider": "registeredAgent", "type": "business" }
]
}
```
The two types are used differently:
* **`business`** is the company's business address on the state formation filing.
* **`mailing`** is where correspondence for the company is directed, including the EIN confirmation letter.
Set an entry to `registeredAgent` and doola resolves its Registered Agent address for the company's `state` automatically, so routing `mailing` to the Registered Agent means correspondence lands with doola.
Choosing the Registered Agent for an address carries **no separate fee**. The only per-company charge is the state formation filing fee, which you can look up per state and entity type from the [state-fees reference endpoint](/api/api-reference/reference-data/list-state-filing-fees).
### Address fields
Within any address, `line1`, `city`, `state`, `postalCode`, and `country` are required; `line2` is optional. `phone` differs by where the address is used:
* **Person addresses** (the `responsibleParty`, each `member`, each executive member): `phone` is **required**.
* **Company addresses** (the `mailing` and `business` entries): `phone` is **optional**.
A `registeredAgent` company entry omits the `address` object entirely, so none of these fields apply to it.
## Non-US founders
doola forms US companies for founders anywhere in the world. When no one on the company has a US tax ID, the IRS requires the founder to sign an **SS-4** before it issues the EIN. The create-company and retrieve-company responses tell you whether this applies through the `signatureRequirements` field, and the [Non-US founders](/api/non-us-formation) guide covers the signing flow end to end. Everything else on this page applies unchanged.
## Expedited EIN
You can request prioritized EIN processing for a **non-US founder** by adding `requestedServices: [{ "service": "EinCreation", "variant": "Expedite" }]` to the create-company request. The [Expedited EIN](/api/expedited-ein) guide covers availability, the request shape, and the errors. Everything else on this page applies unchanged.
## Two things to track
A formation has two separate signals, and confusing them is the most common integration mistake. Decide which question you are answering:
| You want to know | Read | Webhook |
| -------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------- |
| Did doola receive and accept my formation request? | `formationSubmissionStatus` on the company | `company_formation_submitted` |
| Is the company actually formed with the state? | The **Formation** service in [Company services](/api/company-services) | `company_formation_completed` |
`formationSubmissionStatus` is **not** the end-to-end formation status. It only tracks the request handoff and never reaches a "completed" value, so polling it to detect a formed company waits forever. Use the **Formation** service status for that.
### Submission status
`formationSubmissionStatus` tracks how doola is handling your formation request internally. It says nothing about whether the company is formed yet.
When you submit a formation, the company starts at `PENDING`. Once doola confirms it has accepted and started processing the request, it sends the `company_formation_submitted` webhook and the field becomes `SUBMITTED`. If the request cannot be accepted, the field becomes `FAILED` and `company_formation_failed` fires.
| Status | Meaning |
| ----------- | ----------------------------------------------------------------------- |
| `PENDING` | Request received; doola is processing the submission |
| `SUBMITTED` | doola accepted the request and started the formation |
| `FAILED` | Submission failed. Check the company's `adminNotes`, then contact doola |
**How do I know the submission succeeded?** Either signal confirms it:
* you received the `company_formation_submitted` webhook, or
* you fetched the company and `formationSubmissionStatus` is `SUBMITTED`.
This happens asynchronously, so prefer the webhook. Do not sit in a polling loop waiting on the field.
If a company sits in `PENDING` for an extended period, read its `adminNotes` (returned on the get-company endpoint) or contact [engineering@doola.com](mailto:engineering@doola.com).
A formation can also stall because doola needs something from **you** — most often because the state rejected [every company name you submitted](/api/required-actions/name-options). That arrives as a [required action](/api/required-actions) with its own webhook, and the formation resumes once you answer it.
### Formation progress
To tell a customer their company is formed (filed and confirmed by the state), track the **Formation** service rather than the submission status. Each service appears in the company's `services` array, for example:
```json theme={null}
{ "name": "Formation", "status": "InProgress", "subStatus": "WaitingForRaToSucceed" }
```
The Formation service `status` moves from `InProgress` to `Completed`, and the `company_formation_completed` webhook fires when it does:
| status | subStatus | Meaning |
| ------------ | ----------------------- | ------------------------------------------------- |
| `InProgress` | `WaitingForRaToSucceed` | Filing submitted to the state; pending completion |
| `Completed` | *(none)* | Articles of organization/incorporation filed |
[Company services](/api/company-services#statuses) covers the full per-service lifecycle for Formation, EIN, and Registered Agent, including the sub-statuses that show what each service is waiting on.
## Reading progress
The get-company endpoint returns the live state of a formation, including:
* **`formationSubmissionStatus`** — the submission status above; see [Company services](/api/company-services) for the full per-service lifecycle.
* **`ein`** — the Employer Identification Number, populated once issued by the IRS. `null` until then. The `company_ein_issued` webhook fires when it is populated.
* **`formationFilingDate`** — the date the formation was filed with the state. `null` until filed.
* **`formationFilingNumber`** — the filing number the state assigned when the formation was filed. `null` until filed.
* **`adminNotes`** — notes from the doola operations team flagging anything on the company, such as a filing delay or IRS correspondence.
* **`signatureRequirements`** — for a non-US founder, a pending SS-4 entry until the founder signs (plus a pending Form 8821 entry when the company is on an expedited EIN); an empty list for a US founder unless doola has [asked for a Form 8821](/api/required-actions/form8821-required). See [Non-US founders](/api/non-us-formation).
Prefer [webhooks](/api/webhooks) over polling: subscribe to the formation and document events to react the moment something changes. When a delivery is missed, these same fields are how you catch up — see [Reconciliation](/api/webhooks#reconciliation).
## Documents
When formation completes, documents become available and a `document_*` webhook fires for each. Call the document endpoints after receiving the event to list documents and fetch a short lived download URL. Document types are `ArticlesOfOrganization`, `EinLetter`, and `Mail`, plus `SignedSS4` for non-US founders and `SignedForm8821` for non-US founders on an expedited EIN.
# doola Partner API
Source: https://docs.doola.com/api/introduction
Form US companies for your customers and track them to completion, programmatically.
The doola Partner API lets you create customers, submit company formations (LLC or C-Corp), retrieve formation documents, and look up reference data. This guide gets you from zero to your first formation in minutes.
## What you need
* An **API key** for the environment you are targeting, provided by the doola team (looks like `dk_test_...` for sandbox or `dk_live_...` for production).
* A **webhook signing secret**, used to verify incoming webhook events. You generate it yourself in the Partner Portal — see [Webhooks](/api/webhooks#setup).
* Any REST client (curl, Postman, your backend HTTP library).
Don't have credentials yet? [Get API access](https://www.doola.com/partner-with-us/) and the doola team will get you set up.
## Environments
| Environment | Base URL |
| ----------- | ---------------------------- |
| Sandbox | `https://api.test.doola.com` |
| Production | `https://api.doola.com` |
Always build and test against sandbox first. Sandbox keys never trigger real state filings or charges.
## Your first call
Authenticate every request with your API key in the `Authorization` header.
```bash theme={null}
curl https://api.test.doola.com/v1/partner/customers \
-X POST \
-H "Authorization: dk_test_your_api_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-unique-key-001" \
-d '{
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Doe",
"phoneNumber": "+12125550100",
"countryOfResidence": "USA"
}'
```
A successful response returns a `doolaCustomerId`. Save it: you will need it to submit a company formation.
## How formation works
Formation is a two step flow, then asynchronous progress delivered over webhooks.
Call Create customer with the person's details. You get back a `doolaCustomerId`. One customer can own many companies.
Call Create company with that `doolaCustomerId` and the company details. Set `entityType` to `LLC` (the default) or `CCorp` — see [Company formation](/api/formation) for what each one needs. This starts the formation.
Formation is asynchronous. Do not poll. Subscribe to webhook events to learn when formation completes and documents are ready.
## Key concepts
* **Idempotency.** The create endpoints require an `Idempotency-Key` header. Use a unique key per logical operation. Retrying with the same key and body is safe and never duplicates. If a create fails, the key is released — fix the request and retry with the same key. Once a create succeeds, reusing its key with a different body returns `409 E_IDEMPOTENCY_KEY_REUSED`. See [Authentication](/api/authentication#idempotency).
* **Consistent errors.** Every error carries a machine readable `code`, so you can branch on it without parsing strings. See [Errors](/api/errors).
* **Reference data.** Country, state, NAICS, and fee lookups live in the API reference so your forms always submit valid values.
* **Sandbox playground.** Two sandbox-only endpoints complete the formation and EIN milestones on demand, firing the real webhooks, so you can exercise your whole integration without waiting on the state or the IRS. See [Sandbox playground](/api/sandbox-playground).
* **Non-US founders.** doola forms US companies for founders anywhere in the world. A non-US founder signs an SS-4 (and, for an expedited EIN, a Form 8821) before the EIN is issued, and the company response tells you when that applies. See [Non-US founders](/api/non-us-formation).
* **Expedited EIN.** Request prioritized EIN processing for non-US founders at company creation. See [Expedited EIN](/api/expedited-ein).
# Choosing an industry
Source: https://docs.doola.com/api/naics-industry
Classify a company on create with the industry field, where valid values come from, and the validation rules.
The create-company endpoint classifies a company by NAICS through the **`industry`** field. Each value maps to a single classification, so what you send is precisely what the company is filed as.
Source the value from [List NAICS codes](/api/api-reference/reference-data/list-naics-codes): pick an entry and send its `industry` label verbatim on the [create-company](/api/api-reference/companies/create-a-company) request. Treat the reference list as the source of truth rather than hard-coding labels.
```json theme={null}
{
"doolaCustomerId": "31pLCw7sN2aQ9fJkRtY3vXbHmLe",
"state": "WY",
"...": "...",
"industry": "Custom Computer Programming Services"
}
```
The create response and [Get a company](/api/api-reference/companies/get-a-company) echo the resolved `industry`, so you can confirm the classification that was persisted.
## Errors
`industry` is required, and a blank string counts as absent. Failures come back in the standard [validation envelope](/api/errors); branch on `error.fields.industry.code`.
| HTTP | Code | Meaning |
| ---- | -------------------- | ---------------------------------------------------------------- |
| 400 | `E_INDUSTRY_INVALID` | `industry` missing, blank, or not an entry in the reference list |
```json theme={null}
{
"payload": null,
"error": {
"code": "E_VALIDATION_FAILED",
"message": "one or more fields are invalid",
"requestId": "3D2mMaLHsT60sI5qyeHuBlS9TEM",
"fields": {
"industry": { "code": "E_INDUSTRY_INVALID", "message": "invalid industry" }
}
}
}
```
See [Errors](/api/errors) for the response envelope and the global codes.
# Non-US founders
Source: https://docs.doola.com/api/non-us-formation
Form US companies for founders outside the US, where the IRS requires a signed SS-4 — and, for an expedited EIN, a signed Form 8821 — before issuing the EIN.
doola forms US companies (LLC or C-Corp) regardless of where the founder lives. When **no one on the company has a US tax ID**, the IRS requires a signed **SS-4** before it will issue the EIN. The Partner API handles this end to end: doola generates the SS-4, you send the founder a fully whitelabeled signing link, and doola files it once signed.
A non-US founder who also requests an [expedited EIN](/api/expedited-ein) signs one **additional** document, **Form 8821** (Tax Information Authorization), which lets doola retrieve the EIN letter directly from the IRS — the authorization the expedited path relies on. Form 8821 uses the exact same signing flow as the SS-4; only the `documentType` differs.
Everything else about the [formation flow](/api/formation) stays the same. Signing is the only addition, and it only appears for non-US founders.
"US company, non-US founder" is the case this covers. You still form a Wyoming or Delaware entity. What changes is that the founder signs an SS-4 (and, for an expedited EIN, a Form 8821) because they have no SSN or ITIN to put on the EIN application.
## When signing applies
A company needs an **SS-4** signature when **no person on it supplies a US tax ID (SSN or ITIN)**. doola checks every person on the company:
* every LLC `member`,
* every C-Corp `executiveMember`,
* and the `responsibleParty`.
If any one of them has an SSN or ITIN, the company is treated as US and **no signature is required**. If none of them do, it is a non-US founder and **an SS-4 signature is required**.
A **Form 8821** signature is required on top of the SS-4 when the same non-US company **also requested an expedited EIN** (the `EinCreation` service carries the `Expedite` [variant](/api/company-services)). A non-US company on a standard EIN needs only the SS-4; a US company needs neither at formation. doola can later ask any founder for a Form 8821 through a [required action](/api/required-actions/form8821-required) when it could not obtain the EIN online.
| Founder | EIN speed | Requirements |
| ---------------------------- | --------- | -------------------- |
| US (someone has an SSN/ITIN) | any | none |
| Non-US | Standard | `SS4` |
| Non-US | Expedite | `SS4` and `FORM8821` |
You never compute this yourself. doola returns the answer on the company as `signatureRequirements`.
## Detecting the requirement
Both **Create company** and **Retrieve company** return a `signatureRequirements` array. It is always present.
A non-US founder on a standard EIN returns one pending SS-4 requirement:
```json theme={null}
{
"signatureRequirements": [
{ "documentType": "SS4", "status": "PENDING" }
]
}
```
A non-US founder who requested an expedited EIN returns both, each pending until signed:
```json theme={null}
{
"signatureRequirements": [
{ "documentType": "SS4", "status": "PENDING" },
{ "documentType": "FORM8821", "status": "PENDING" }
]
}
```
A US founder returns an empty array, meaning nothing to sign:
```json theme={null}
{
"signatureRequirements": []
}
```
| Field | Values | Meaning |
| -------------- | ---------------------- | ---------------------------------------------------------- |
| `documentType` | `SS4`, `FORM8821` | The document the founder must sign. |
| `status` | `PENDING`, `COMPLETED` | `PENDING` until that document is signed, then `COMPLETED`. |
Branch your onboarding on `signatureRequirements`. A non-empty array means you need to collect one or more signatures before formation can finish; an empty array means you are done after Create company. Each entry is independent — request a session, remind, and track completion per `documentType`.
## The signing flow
The flow below shows the SS-4. **Form 8821 is identical** — request a session with `documentType: "FORM8821"`, and the reminder and completion webhooks are the `signature_form8821_*` / `document_form8821_uploaded` equivalents. When both are required, run them independently (in parallel or in any order).
```mermaid theme={null}
sequenceDiagram
participant P as Your backend
participant D as doola API
participant U as Founder
P->>D: Create company (non-US founder)
D-->>P: signatureRequirements includes SS4 PENDING (and FORM8821 PENDING when expedited)
P->>D: POST /companies/{companyId}/signatures with the documentType
D-->>P: signing session (url, expiresAt)
P->>U: Request the founder for signature
Note over D,P: Not signed yet
D-->>P: webhook signature reminder day 1, 3, 7 (ss4 and form8821 events)
Note over P: Request a fresh session, re-send
U-->>D: Founder signs
D-->>P: webhook signature completed (ss4 or form8821)
D-->>P: webhook document uploaded (ss4 or form8821)
Note over D: doola files the documents and retrieves the EIN
D-->>P: webhook document_einletter_uploaded (EIN issued)
```
## Request a signing session
Create a signing session whenever you are about to present a document to the founder, passing the `documentType` you are collecting (`SS4` or `FORM8821`). The signing link is short lived, so always mint a fresh one right before you show it rather than storing it.
See [Create a signature session](/api/api-reference/signatures/create-a-signature-session) in the API reference for the request body, response fields, and status codes.
### About the signing link
* **It is fully whitelabeled.** The founder sees no doola branding and no third-party signing-provider branding. It is your flow, end to end.
* **Always request a fresh session before presenting it.** Each new session voids any earlier unsigned session for the same document, so an old link stops working once you mint a new one. Generating a session on every "sign now" action keeps the link valid and avoids handing out a link that has already expired.
Do not cache or email a signing `url` for later. Links expire two hours after issuance, and a newer session voids an older one. If a founder opens a stale or voided link, the redirect returns `410 E_SIGNATURE_URL_EXPIRED`. Mint a new session at the moment of signing instead.
## Reminders are yours to send
doola does not email or chase the founder. You own the reminder cadence. To help, doola emits a reminder webhook on **day 1, day 3, and day 7** after each document is generated, while it remains unsigned: `signature_ss4_reminder_due` for the SS-4 and `signature_form8821_reminder_due` for the Form 8821.
When you receive one:
1. Request a **fresh** signing session for that `documentType` (the link from the original request has likely expired).
2. Re-present it to the founder, however you nudge users (email, in-app, push).
Reminders stop automatically once that document is signed. There is no separate expiry event after day 7; the cadence simply ends.
Subscribe to the reminder events only if you want doola to drive your reminder timing. If you run your own cadence, you can ignore them and poll `signatureRequirements` or watch for the completion events instead.
## Completion
For each document, two events fire when the founder finishes, at two distinct moments:
| Event | Fires when |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `signature_ss4_completed` / `signature_form8821_completed` | The instant the founder signs. React here to update your UI immediately. |
| `document_ss4_uploaded` / `document_form8821_uploaded` | Once the signed PDF is stored and downloadable through the [document endpoints](/api/domain-objects#document). |
At the same time, that document's `signatureRequirements[].status` flips from `PENDING` to `COMPLETED` on the company.
After that, formation continues exactly as it does for US founders: doola files the signed documents with the IRS, and the EIN follows. Watch `company_ein_issued`, `document_einletter_uploaded`, and the **Formation** service status, as described in [Company formation](/api/formation#two-things-to-track).
Need the EIN faster? Non-US founders qualify for the [expedited EIN](/api/expedited-ein), requested at company creation with `requestedServices`. The SS-4 signing flow on this page still applies; only the EIN processing is prioritized.
## Errors
These are specific to the signing endpoints. Branch on `error.code`, never on the message.
| HTTP | Code | Meaning |
| ---- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_DOCUMENT_TYPE_NOT_SUPPORTED` | The document type cannot be signed through this endpoint. Supported types are `SS4` and `FORM8821`. |
| 404 | `E_NOT_FOUND` | No company with this ID belongs to your tenant. |
| 409 | `E_DOCUMENT_ALREADY_SIGNED` | The requested document is already signed. Read `signatureRequirements` to confirm its `COMPLETED` status. |
| 410 | `E_SIGNATURE_URL_EXPIRED` | The signing link is expired, voided, or tampered with. Request a new session and present the fresh link. |
| 422 | `E_FORMATION_NOT_ELIGIBLE` | The company does not require this document — e.g. a US company (someone has an SSN or ITIN), or `FORM8821` requested for a company that is not on an expedited EIN. |
See [Errors](/api/errors) for the response envelope and the global codes that any endpoint can return.
# OpenAPI specification
Source: https://docs.doola.com/api/openapi-spec
Download the machine-readable OpenAPI document for the doola Partner API.
The doola Partner API is described by a machine-readable OpenAPI 3 document. It is generated directly from the live API, so it always matches the current contract.
The full specification in JSON.
Use it to:
* generate a typed client or SDK (for example with [openapi-generator](https://openapi-generator.tech)),
* import the API into Postman, Insomnia, or Bruno,
* power your own validation and tooling.
# Required actions
Source: https://docs.doola.com/api/required-actions
Respond when doola needs something from you before a company's formation can continue.
Most of a formation runs without you. Occasionally it cannot: the state rejects every company name you submitted, a document has to be signed again, or doola needs a signature it did not ask for at formation. When that happens doola raises a **required action** on the company, tells you over a webhook, and waits.
A required action is the one thing in the Partner API that blocks on you. Until it is resolved, that company does not progress — its formation, or the EIN that follows it — so treat these events as work items, not notifications.
This page covers what every required action has in common: how you are told, how to read them, and how to recover the ones you missed. Each kind has its own page for the part that differs — how you actually clear it.
`FORMATION_NAME_OPTIONS_EXHAUSTED` — the state rejected every name you submitted. You send replacements.
`FORMATION_SIGNATURE_SS4_RESET` — a name change voided a signed SS-4. Your founder signs again.
`FORMATION_SIGNATURE_FORM8821_REQUIRED` — doola could not obtain the EIN online. Your founder signs a Form 8821.
A closed action is never reopened. If the same thing is needed again, doola raises a **new** action with a fresh `requiredActionId`. Key your records on `requiredActionId`, not on the company plus the code.
## Anatomy
Every action carries a `requiredActionId`, an `actionCode` that says what is needed, and a `reason` written for a person to read. These are the three you build against:
| Field | Notes |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `requiredActionId` | Identifier for the action. Resolve and read against it |
| `actionCode` | `FORMATION_NAME_OPTIONS_EXHAUSTED`, `FORMATION_SIGNATURE_SS4_RESET` or `FORMATION_SIGNATURE_FORM8821_REQUIRED`. Branch on this |
| `reason` | Explanation to render to your user verbatim. Never parse it |
| `status` and `open` | Where the action has got to; see [Statuses](#statuses) |
Full field list: [Required action](/api/domain-objects#required-action).
For name rejections the `reason` usually names the exact names the state turned down. When doola could not capture them it falls back to a fixed sentence, `All submitted company name options were rejected by the state. Submit new options.`
## Statuses
An action's `status` is the last thing that happened to it. `open` tells you whether it still needs attention.
| Status | Set by | Meaning |
| ----------------- | ------ | ------------------------------------------------------------------------------------------------------- |
| `created` | doola | The action was raised |
| `delivered` | doola | The webhook reached your endpoint. The action is still open and still needs your answer |
| `delivery_failed` | doola | doola could not reach your endpoint after all retries — you will only find this action by listing |
| `submitted` | you | Your resolution was received and is being processed |
| `resolved` | doola | The resolution was accepted; the action is closed |
| `rejected` | doola | The resolution was not accepted, for example the state rejected the new names too; the action is closed |
`resolved` and `rejected` are terminal, and `open` is then `false`. Every other status means the action is still open.
`submitted` is not done. It means doola has your answer and is acting on it; for name options that means [refiling with the state](/api/required-actions/name-options#the-flow). Wait for the action to close before telling your user the matter is settled.
## How doola tells you
Each code has its own webhook event, and all of them carry the same payload shape.
| Event | Raised for |
| ------------------------------- | ---------------------------------------------------------------------------------- |
| `company_name_options_required` | [`FORMATION_NAME_OPTIONS_EXHAUSTED`](/api/required-actions/name-options) |
| `signature_ss4_reset` | [`FORMATION_SIGNATURE_SS4_RESET`](/api/required-actions/ss4-reset) |
| `signature_form8821_required` | [`FORMATION_SIGNATURE_FORM8821_REQUIRED`](/api/required-actions/form8821-required) |
```json theme={null}
{
"eventId": "31pLdpvMh4OfO90moxLgLT8AuQr",
"eventName": "company_name_options_required",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"requiredActionId": "31wR7kM2nP5qT8vXyZaBcDeFgHj",
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"actionName": "New company names needed",
"reason": "The state rejected “Acme Holdings LLC” and “Acme Labs LLC”. Submit new company name options to continue the formation."
},
"timestamp": 1756216157000
}
```
Delivery follows the standard [webhook contract](/api/webhooks#delivery-and-retries), so treat it as at least once and dedupe on `eventId`. An action doola never managed to deliver is still open and still blocking — see [Reconciling](#reconciling).
## Reading actions
### Everything still open
One call, across every company you own. This is what a dashboard or a startup check should read.
```bash theme={null}
curl "https://api.test.doola.com/v1/partner/required-actions?page=0&size=20" \
-H "Authorization: dk_test_your_api_key_here"
```
```json theme={null}
{
"payload": {
"content": [
{
"requiredActionId": "31wR7kM2nP5qT8vXyZaBcDeFgHj",
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"actionName": "New company names needed",
"status": "delivered",
"reason": "The state rejected “Acme Holdings LLC”. Submit new company name options to continue the formation.",
"open": true,
"updatedAt": "2026-08-13T17:33:12.751020"
}
],
"page": 0,
"size": 20,
"total": 1,
"totalPages": 1
},
"error": null
}
```
Closed actions are excluded. `total` is the tenant-wide open count whatever page size you ask for, so `size=1` is a cheap "is anything waiting on me?" check. `size` is capped at 100 — a larger value is clamped rather than rejected.
### One company
```bash theme={null}
curl https://api.test.doola.com/v1/partner/companies/{companyId}/required-actions \
-H "Authorization: dk_test_your_api_key_here"
```
```json theme={null}
{
"payload": [
{
"requiredActionId": "31wR7kM2nP5qT8vXyZaBcDeFgHj",
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"actionName": "New company names needed",
"status": "resolved",
"reason": "The state rejected “Acme Holdings LLC”. Submit new company name options to continue the formation.",
"open": false,
"updatedAt": "2026-08-14T09:02:44.118220"
}
],
"error": null
}
```
A plain array under `payload`, unpaged, most recently changed first. Unlike the open list this one keeps `resolved` and `rejected` entries, which is what lets you tell "nothing was ever needed" apart from "something was needed and I missed it".
### One action, with history
```bash theme={null}
curl https://api.test.doola.com/v1/partner/companies/{companyId}/required-actions/{requiredActionId} \
-H "Authorization: dk_test_your_api_key_here"
```
Adds a `history` array: every step the action has been through, oldest first, including whether doola reached your endpoint and what you submitted.
```json theme={null}
{
"payload": {
"requiredActionId": "31wR7kM2nP5qT8vXyZaBcDeFgHj",
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"actionName": "New company names needed",
"status": "submitted",
"reason": "The state rejected “Acme Holdings LLC”. Submit new company name options to continue the formation.",
"open": true,
"updatedAt": "2026-08-13T17:33:32.624556",
"history": [
{ "status": "created", "createdAt": "2026-08-13T17:33:12.502357" },
{ "status": "delivered", "createdAt": "2026-08-13T17:33:12.751020" },
{
"status": "submitted",
"submittedPayload": {
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"nameOptions": [{ "name": "Acme Ventures", "entityTypeEnding": "LLC" }]
},
"createdAt": "2026-08-13T17:33:32.624556"
}
]
},
"error": null
}
```
`submittedPayload` appears on `submitted` steps only. It is a record of what you sent at that moment, not the company's current state — read the company for that.
## Reconciling
Webhooks can be missed — downtime on your side, or an endpoint doola disabled after repeated failures. An action left in `delivery_failed` is still open and still blocking that company, and no further webhook announces it.
Guard against that with one call at startup and on a schedule:
```bash theme={null}
curl "https://api.test.doola.com/v1/partner/required-actions?size=100" \
-H "Authorization: dk_test_your_api_key_here"
```
Anything there you have no record of is work you missed. Compare `total` against what you received and page with `page=1`, `page=2`, and so on if you ever hold more than 100 open actions at once; for most tenants this is a single request no matter how many companies you have.
## Testing
The [sandbox playground](/api/sandbox-playground) cannot raise a required action today. To exercise the flow end to end in sandbox, ask doola to raise one on a sandbox company at [engineering@doola.com](mailto:engineering@doola.com) — the webhook, the reads, and the resolution endpoint then behave exactly as they do in production. The reads are safe to build against meanwhile: a company with nothing outstanding returns an empty list.
## Errors
Branch on `error.code`, never on the message. The 400-level codes below come from [submitting a resolution](/api/required-actions/name-options#submitting-new-names); the 404s are returned by the reads as well.
| HTTP | Code | Meaning |
| ---- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `E_VALIDATION_FAILED` | Body shape is wrong: `actionCode` missing, or `nameOptions` missing, empty, more than three, or carrying an unrecognised `entityTypeEnding`. The `error.fields.nameOptions` detail names the rule that failed |
| 400 | `E_REQUIRED_ACTION_CODE_MISMATCH` | `actionCode` does not match the action you are resolving — you likely mixed up two open actions |
| 400 | `E_NAME_OPTIONS_INVALID` | A recognised ending that does not belong to the company's entity type, for example a CCorp ending on an LLC |
| 404 | `E_REQUIRED_ACTION_NOT_FOUND` | No action with this ID on this company, including an action that belongs to another partner |
| 404 | `E_COMPANY_NOT_FOUND` | No company with this ID belongs to your tenant |
| 409 | `E_REQUIRED_ACTION_CLOSED` | The action is already resolved or rejected. A closed action never reopens; wait for the new one |
| 422 | `E_REQUIRED_ACTION_NOT_RESOLVABLE` | This action is resolved by another flow, such as an [SS-4 re-signature](/api/required-actions/ss4-reset) or a [Form 8821 signature](/api/required-actions/form8821-required) |
Checks run in a fixed order — body shape, company scope, action scope, `actionCode` match, resolvability, then whether the action is still open — so a request with more than one problem reports the first failure in that list. A closed signature action therefore answers `422`, not `409`.
Anything that is not yours is a `404`, never a `403` — a `403` would confirm the ID exists, which is enough to enumerate another partner's companies.
See [Errors](/api/errors) for the response envelope and the global codes that any endpoint can return.
# Form 8821 signature needed
Source: https://docs.doola.com/api/required-actions/form8821-required
Handle FORMATION_SIGNATURE_FORM8821_REQUIRED by collecting the Form 8821 signature doola needs to obtain the company's EIN.
Most founders never sign a Form 8821. A non-US founder on an expedited EIN signs one at formation; everyone else does not, because doola obtains the EIN without it. Occasionally that route fails, and a signed Form 8821 (Tax Information Authorization) is what lets doola retrieve the EIN from the IRS directly. When that happens doola raises `FORMATION_SIGNATURE_FORM8821_REQUIRED` on the company.
You are told by the `signature_form8821_required` webhook. See [Required actions](/api/required-actions) for the payload, the status model, and how to read actions back.
This action is **not** resolved through the [resolution endpoint](/api/required-actions/name-options#submitting-new-names). It closes by itself when the signature completes. Posting a resolution for it returns `422 E_REQUIRED_ACTION_NOT_RESOLVABLE`.
## When it fires
Only when a doola operator asks for it; there is no automatic trigger. By then the formation itself is usually complete, and doola's attempt to obtain the EIN on the founder's behalf has failed in a way a Form 8821 fixes. It can apply to **any** founder, US or non-US, and to a company that already signed a Form 8821 at formation: in that case the earlier signature is voided and the requirement returns to `PENDING`.
## What changes on the company
While the action is open, the company's `signatureRequirements` carries a `FORM8821` entry with `status: "PENDING"`, including for a US founder whose array is otherwise empty. Read the company and you see the ask; you do not need the webhook to have arrived.
```json theme={null}
{
"signatureRequirements": [
{ "documentType": "FORM8821", "status": "PENDING" }
]
}
```
Once signed, the entry flips to `COMPLETED` and stays on the company.
## Resolving it
Exactly as a Form 8821 is signed at formation: create a signature session with `documentType: "FORM8821"` and present the link to your founder, as described in [Non-US founders](/api/non-us-formation#request-a-signing-session). The session endpoint accepts the request while the action is open, whatever the founder's residency. Without an open action a US company still receives `422 E_FORMATION_NOT_ELIGIBLE`.
That signature is the resolution: `signature_form8821_completed` and `document_form8821_uploaded` fire as usual, and the action lands on `resolved`.
The [reminder cadence](/api/non-us-formation#reminders-are-yours-to-send) starts when you create the first signing session, so `signature_form8821_reminder_due` arrives on day 1, 3 and 7 after that while the document is unsigned. It does not start from this webhook: a founder you never present the link to is never reminded, so treat the action itself as the thing to track.
## When doola closes it without a signature
doola may obtain the EIN another way while the action is open. When that happens an operator closes the action by hand and it lands on `resolved` with no signature having been given. Treat `resolved` as "nothing more is needed from you", not as "the founder signed"; read `signatureRequirements` if you need to know which.
## What to show your user
The `reason` says it in one sentence: doola could not obtain the company's EIN online, and a signed Form 8821 authorizes doola to retrieve it from the IRS directly. The form does not change the company and the founder is not redoing anything. It is an authorization, and presenting it that way answers the question of why a formed company still has paperwork.
# New company names needed
Source: https://docs.doola.com/api/required-actions/name-options
Handle FORMATION_NAME_OPTIONS_EXHAUSTED by submitting replacement company names after the state rejects every option.
`FORMATION_NAME_OPTIONS_EXHAUSTED` is raised when the state rejects **every** company name you submitted. The formation stops there until you send replacements, and the `reason` on the action usually names the exact names that were turned down, so you can put them in front of your user.
You are told by the `company_name_options_required` webhook. See [Required actions](/api/required-actions) for the payload, the status model, and how to read actions back.
## The flow
```mermaid theme={null}
sequenceDiagram
participant U as Your user
participant P as Your backend
participant D as doola API
participant TP as ThirdParty (State)
TP-->>D: Rejects every name option
D->>D: Opens a required action
D-->>P: webhook company_name_options_required
P->>U: Ask for new company names
U->>P: New names
P->>D: Submit resolution
D-->>P: status: submitted
D->>TP: doola refiles with the state
alt State accepts a name
TP-->>D: Accepted
D->>D: Action resolved (closed)
D-->>P: Formation continues
else State rejects again
TP-->>D: Rejected
D->>D: Action rejected (closed)
D-->>P: webhook company_name_options_required (new action)
end
```
Refiling and the accept-or-reject outcome are handled by doola's operations team, so expect an action to sit in `submitted` for as long as the state takes.
## Submitting new names
One endpoint resolves every resolvable action, and `actionCode` says which one you are answering — it must match the action's own `actionCode`, or the request is rejected.
```bash theme={null}
curl https://api.test.doola.com/v1/partner/companies/{companyId}/required-actions/{requiredActionId}/resolution \
-X POST \
-H "Authorization: dk_test_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"nameOptions": [
{ "name": "Acme Ventures", "entityTypeEnding": "LLC", "position": 1 },
{ "name": "Acme Labs", "entityTypeEnding": "LLC", "position": 2 }
]
}'
```
The response is the updated action, `status: "submitted"`, with its full `history` — you do not need a follow-up read.
The names replace the company's existing options immediately, and the first-preference option also becomes the company's `name` and `entityTypeEnding`. A company you read back afterwards reflects what you just sent, so refresh anything you cached.
This endpoint takes no `Idempotency-Key`. Two identical submissions apply the names twice and append two `submitted` steps, so send it once and treat a network timeout as "read the action back" rather than "retry blindly".
## Name option rules
The same rules as [creating a company](/api/domain-objects#name-option), because these names go to the state the same way.
`position` sets the order and `1` is the first choice; omit it and doola uses the order you sent. The first preference becomes the company's name if the state accepts it.
`entityTypeEnding` must be one of the endings listed under [Name option](/api/domain-objects#name-option). This check is case-sensitive, so send `LLC`, not `llc`.
An LLC takes an LLC ending and a CCorp takes a corporate ending. An ending that is recognised but belongs to the other entity type is rejected rather than filed.
Breaking any of these comes back as `400 E_VALIDATION_FAILED`, with the detail under `error.fields.nameOptions` — except an ending that is recognised but belongs to the wrong entity type, which is `400 E_NAME_OPTIONS_INVALID`. The [full error table](/api/required-actions#errors) lists the rest.
## If the state rejects again
The action closes as `rejected` and doola raises a **new** action with the same code and a fresh `requiredActionId`, which arrives as another `company_name_options_required` webhook. Nothing reopens, so resolve against the new id — this is why your records should be keyed on `requiredActionId` rather than on the company.
## After it resolves
Once a name is accepted the action closes as `resolved` and the formation continues from where it stopped.
One thing follows on: if your founder had **already signed an SS-4** against the old name, that signature is now void, and doola raises an [SS-4 signature needed again](/api/required-actions/ss4-reset) action for the replacement. Expect it as a second, separate webhook.
# SS-4 signature needed again
Source: https://docs.doola.com/api/required-actions/ss4-reset
Handle FORMATION_SIGNATURE_SS4_RESET by re-presenting the SS-4 after a name change voids an existing signature.
An SS-4 is signed against a specific company name. When a replacement name is accepted after a rejection round, the signature your founder already gave no longer matches the company, so it is void. doola flips the company's `SS4` [signature requirement](/api/domain-objects#signature-requirement) back to `PENDING` and raises `FORMATION_SIGNATURE_SS4_RESET`.
You are told by the `signature_ss4_reset` webhook. See [Required actions](/api/required-actions) for the payload, the status model, and how to read actions back.
This action is **not** resolved through the [resolution endpoint](/api/required-actions/name-options#submitting-new-names). It closes by itself when the replacement signature completes. Posting a resolution for it returns `422 E_REQUIRED_ACTION_NOT_RESOLVABLE`.
## When it fires
It is narrow. All three have to be true:
* the founder is **non-US**, so an SS-4 was required at all,
* they had **already signed** it, and
* a [replacement name](/api/required-actions/name-options) was then accepted.
A founder who had not signed yet gets no action and no event — their pending SS-4 simply carries the new name when they do sign. A US-founder company never has an SS-4 in the first place, so this never applies.
## Resolving it
Exactly as you handled the first signature: create a signature session and present the link to your founder, as described in [Non-US founders](/api/non-us-formation#request-a-signing-session). Nothing about the signing flow changes — the link is short-lived and the founder signs the regenerated SS-4 carrying the new company name.
That signature is the resolution: the action lands on `resolved`, and the EIN application continues.
The usual [SS-4 reminder cadence](/api/non-us-formation#reminders-are-yours-to-send) resumes with the reset, so `signature_ss4_reminder_due` keeps arriving until the replacement is signed. Treat those as you already do — they are the same reminders, now for the new signature.
## What to show your user
The `reason` on this action is short (`SS4 must be signed again.`), so the useful context is what preceded it: their company got a new name, and the form authorizing doola to obtain the EIN has to match it. Presenting the signing link with that framing avoids a founder wondering why they are signing the same document twice.
# Sandbox playground
Source: https://docs.doola.com/api/sandbox-playground
Complete formation and EIN issuance on demand in sandbox, and receive every webhook without waiting on the state or the IRS.
In production, two formation milestones happen on timelines you cannot control: the state confirms the formation, and the IRS issues the EIN. The sandbox playground gives you an endpoint for each, so you can drive a test company through the full lifecycle and receive every webhook on your endpoint in minutes, with no doola involvement.
**Sandbox only.** The playground exists only on `https://api.test.doola.com`; the routes do not exist in production. Authenticate with your sandbox API key (`dk_test_...`), like any other request.
## The two milestones
| Endpoint | Simulates | Webhooks fired |
| ------------------------------------------------------------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/partner/playground/companies/{companyId}/formation/complete` | The state confirming the formation | `company_formation_completed`, `document_aoo_uploaded`, plus `document_corporatebylaws_uploaded` for a CCorp or `document_operatingagreement_uploaded` for an LLC |
| `POST /v1/partner/playground/companies/{companyId}/eincreation/complete` | The IRS issuing the EIN | `company_ein_issued`, `document_einletter_uploaded` |
Each endpoint runs the same operations production runs when the real milestone happens. Webhooks are emitted by the normal pipeline: signed, filtered by your event subscriptions, retried on failure, and visible in the Partner Portal delivery logs. Documents go through the real upload flow, and service statuses advance exactly as they do in production. The playground never fabricates an event, so what you build against it holds in production.
## Walkthrough
You need a sandbox API key and a webhook endpoint configured in the Partner Portal (see [Webhooks](/api/webhooks#setup)). If you just want to watch events arrive before wiring up your backend, a request inspector such as webhook.site works fine as the endpoint.
Use the regular [Create customer and Create company](/api/introduction#your-first-call) calls with your sandbox key. Shortly after the company is created, `company_formation_submitted` arrives at your webhook endpoint and `formationSubmissionStatus` becomes `SUBMITTED`. Nothing playground-specific yet: that event follows the normal create flow, in sandbox as in production, which is why the playground has no endpoint for it.
Trigger the "state confirms formation" milestone:
```bash theme={null}
curl https://api.test.doola.com/v1/partner/playground/companies/{companyId}/formation/complete \
-X POST \
-H "Authorization: dk_test_your_api_key_here"
```
```json theme={null}
{
"payload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"service": "Formation",
"triggeredEvents": ["company_formation_completed", "document_aoo_uploaded"]
},
"error": null
}
```
Your endpoint receives `company_formation_completed` and `document_aoo_uploaded`. On the company, `formationFilingDate` is set, `formationFilingNumber` is set to the fixed test value `PLAYGROUND-FILING`, the Articles of Organization appear in the documents list, and the **Formation** service advances to `Completed`. `triggeredEvents` additionally lists the entity type's governance document event — `document_corporatebylaws_uploaded` for a **CCorp**, `document_operatingagreement_uploaded` for an **LLC**: doola generates the governance document shortly after the Articles are processed, and the event and document follow, exactly as in production.
Trigger the "IRS issues the EIN" milestone:
```bash theme={null}
curl https://api.test.doola.com/v1/partner/playground/companies/{companyId}/eincreation/complete \
-X POST \
-H "Authorization: dk_test_your_api_key_here"
```
```json theme={null}
{
"payload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"service": "EinCreation",
"triggeredEvents": ["company_ein_issued", "document_einletter_uploaded"]
},
"error": null
}
```
Your endpoint receives `company_ein_issued` and `document_einletter_uploaded`. On the company, `ein` is populated with the test value `12-3456789`, the EIN letter appears in the documents list, and the **EinCreation** service advances to `Completed`.
Fetch the company and confirm `formationFilingDate`, `ein`, and the `services[].status` values; list its documents and download each one; and check the delivery log in the Partner Portal, which records every attempt with its HTTP status.
Call the endpoints in this order to mirror the real sequence. The order is not enforced, but a formation that gains an EIN before it is formed is not a state production will ever show you.
## What to expect
* **Statuses advance asynchronously.** The Formation and EinCreation `status` values (see [Company services](/api/company-services)) are derived by doola's systems moments after each call, not atomically within the response. React to the webhooks, or allow a short delay before asserting on the company, rather than asserting synchronously.
* **Documents are placeholders.** The Articles of Organization and EIN letter are placeholder PDFs, but they flow through the real pipeline: list them and fetch download URLs exactly as in production. A CCorp's Corporate Bylaws and an LLC's Operating Agreement are generated from the company's real formation data, exactly as in production.
* **Test values are fixed.** The EIN is always `12-3456789`, and `formationFilingDate` is the date you made the call. A test note also appears in the company's `adminNotes`.
* **Repeat calls re-fire webhooks.** Calling a playground endpoint again re-runs the milestone: `company_formation_completed` fires again, and the document events fire again with a fresh `documentId`. That makes repeat calls a handy way to re-test your handler and its idempotency. The exception is `company_ein_issued`, which fires only on the first issuance, exactly as in production — a repeat `eincreation/complete` therefore lists only `document_einletter_uploaded` in `triggeredEvents`.
## What the playground does not simulate
The playground models the standard US-founder flow. It has no endpoint for:
* `company_formation_failed` and `company_filing_date_updated`
* `document_mail_uploaded` (business mail)
* The non-US founder signing milestones (the `signature_*` and SS-4 / Form 8821 document events); see [Non-US founders](/api/non-us-formation)
* [Required actions](/api/required-actions) (`company_name_options_required`, `signature_ss4_reset`); ask [engineering@doola.com](mailto:engineering@doola.com) to raise one on a sandbox company
## Errors
| HTTP | Code | Meaning |
| ---- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 404 | `E_COMPANY_NOT_FOUND` | No company with this ID exists for the authenticated partner. The playground never reveals whether the ID belongs to another tenant. |
The [global error codes](/api/errors#global-error-reference) apply as usual. In production the routes themselves do not exist, so any call there returns a plain 404.
# Webhooks
Source: https://docs.doola.com/api/webhooks
Receive formation and document events, and verify their signatures.
Formation is asynchronous. Rather than polling, subscribe to webhook events to learn when a formation progresses and when documents become available.
## Setup
In the doola Partner Portal, open **Settings → Webhook URL** and generate your signing secret. It is shown **once** — copy it into your secret manager before you leave the page. Generating requires an active API key on the tenant, so mint one under **Settings → API Keys** first.
Rotating is the same button and takes effect immediately: the previous secret stops signing the moment the new one is issued, and deliveries fail your verification until you have stored the new value. Rotate when you can deploy the new secret, not mid-incident.
Set your webhook URL in the same **Settings → Webhook URL** tab. The URL must be HTTPS. Each environment has its own tenant, so configure sandbox and production separately — each has its own signing secret.
To see real deliveries end to end without waiting on an actual formation, use the [Sandbox playground](/api/sandbox-playground): it completes the formation and EIN milestones on demand and fires the events below through the normal pipeline.
## Verifying the signature
doola sends a `POST` with an `X-Doola-Signature` header: an HMAC SHA256 hex digest of the raw request body, keyed with your webhook secret. (HTTP header names are case-insensitive; many frameworks lowercase them to `x-doola-signature` on the way in.)
```javascript Node.js theme={null}
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, webhookSecret) {
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
const expectedBuffer = Buffer.from(expected);
const signatureBuffer = Buffer.from(signature ?? '');
// timingSafeEqual throws if the buffers differ in length, so reject that first.
if (expectedBuffer.length !== signatureBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}
```
```python Python theme={null}
import hmac, hashlib
def verify_webhook(raw_body: bytes, signature: str, webhook_secret: str) -> bool:
expected = hmac.new(
webhook_secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
```
Always use a constant time comparison (`timingSafeEqual` or `compare_digest`). Plain string equality is vulnerable to timing attacks.
## Payload
Every delivery is a `POST` with the same envelope. Only `eventPayload` changes shape from event to event.
```json theme={null}
{
"eventId": "31pLdpvMh4OfO90moxLgLT8AuQr",
"eventName": "document_aoo_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31pLdj8SKAU9BYNchcohyto2AxC",
"documentType": "ArticlesOfOrganization",
"documentCreatedAt": "2025-08-26 13:49:16.0"
},
"timestamp": 1756216157000
}
```
| Field | Type | Description |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------ |
| `eventId` | string | Unique ID for this event. Use it to dedupe redeliveries. |
| `eventName` | string | Which event fired (see the table below). |
| `eventPayload` | object \| null | Event-specific body. Shape depends on `eventName`, and is `null` for `partner_webhook_disabled`. |
| `timestamp` | number | When the event fired, Unix epoch in **milliseconds**. |
## Events
| Event | Description |
| -------------------------------------- | ----------------------------------------------------------------------------- |
| `company_formation_submitted` | Formation accepted and processing started |
| `company_formation_completed` | Formation complete, company is active |
| `company_formation_failed` | Formation failed after all retries; inspect `reasonCode` and `message` |
| `company_filing_date_updated` | Filing date was updated by doola |
| `company_ein_issued` | The company's EIN was issued; fetch the company to read `ein` |
| `document_aoo_uploaded` | Articles of Organization ready to download |
| `document_einletter_uploaded` | EIN Letter ready to download |
| `document_mail_uploaded` | Business mail document uploaded |
| `document_corporatebylaws_uploaded` | Corporate Bylaws ready to download (CCorp only) |
| `document_operatingagreement_uploaded` | Operating Agreement ready to download (LLC only) |
| `signature_ss4_completed` | A non-US founder signed their SS-4 |
| `document_ss4_uploaded` | Signed SS-4 stored and ready to download |
| `signature_ss4_reminder_due` | An unsigned SS-4 is due a reminder (day 1, 3, 7) |
| `signature_form8821_completed` | A non-US founder on an expedited EIN signed their Form 8821 |
| `document_form8821_uploaded` | Signed Form 8821 stored and ready to download |
| `signature_form8821_reminder_due` | An unsigned Form 8821 is due a reminder (day 1, 3, 7) |
| `company_name_options_required` | The state rejected every company name; new options are needed from you |
| `signature_ss4_reset` | A replacement name was accepted, voiding an SS-4 that had already been signed |
| `signature_form8821_required` | doola needs the founder to sign a Form 8821 so it can obtain the EIN |
| `partner_webhook_disabled` | Your endpoint was automatically disabled (see below) |
You may occasionally receive duplicate `document_*` events for the same document, for example after doola re-issues a document. Handle uploads idempotently using `documentId`.
The four SS-4 events fire only for **non-US founders**, who must sign an SS-4 before the IRS issues the EIN. The three `form8821` **signing milestones** — completed, uploaded, and reminder — mirror the SS-4 ones and fire for whoever is asked to sign a Form 8821: a non-US founder who **also requested an expedited EIN**, where the signed form authorizes doola to retrieve the EIN letter from the IRS, or any founder doola has asked through a [Form 8821 request](/api/required-actions/form8821-required). In each pair, `signature_*_completed` fires the instant the founder signs; `document_*_uploaded` fires once the signed PDF is stored and downloadable. `signature_ss4_reset` fires only when a non-US founder had already signed and a later name change voided that signature. `signature_form8821_required` is the one Form 8821 event that does not depend on residency at all: doola raises it when it could not obtain the EIN online. See [Non-US founders](/api/non-us-formation) for the full flow.
`company_ein_issued` and `document_einletter_uploaded` both signal EIN issuance, at two distinct moments: `company_ein_issued` fires when doola records the EIN on the company (fetch the company to read the `ein` field), and `document_einletter_uploaded` fires once the IRS confirmation letter is stored and downloadable. They arrive independently, so do not assume an order. `company_ein_issued` fires only on the first issuance; a later correction to the EIN value does not re-fire it.
`company_name_options_required`, `signature_ss4_reset` and `signature_form8821_required` announce a [required action](/api/required-actions) — something doola needs from you before that company can continue. They are the only events that block progress until you act. All three carry a `requiredActionId`: key your records on it and read the action against it. Only `company_name_options_required` is answered through the resolution endpoint; the two signature actions close themselves once the signature completes. An action you never received, because your endpoint was down, stays open and is readable from the [open list](/api/required-actions#reconciling).
The two governance-document events are entity-type specific: `document_corporatebylaws_uploaded` fires for **CCorp** companies only, and `document_operatingagreement_uploaded` fires for **LLC** companies only. doola generates the governance document shortly after the Articles of Organization are processed, so each event typically arrives soon after `document_aoo_uploaded`.
### Example payloads
A sample delivery for every event. Within `eventPayload`, only the fields relevant to that event are present; inapplicable fields are omitted entirely rather than sent as `null`, so parse defensively. `eventPayload` itself is `null` only for `partner_webhook_disabled`.
```json theme={null}
{
"eventId": "31pLeQk8Mcp7rSflgdr1c2ZV2th",
"eventName": "company_formation_submitted",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756216100000
}
```
```json theme={null}
{
"eventId": "31q8RtBnVGPk26B6ZqeUFeAaMBE",
"eventName": "company_formation_completed",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756392000000
}
```
```json theme={null}
{
"eventId": "31qC2fK9mNpL7vWxYzAbCdEfGhJ",
"eventName": "company_formation_failed",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"doolaCustomerId": "31pLCw7sN2aQ9fJkRtY3vXbHmLe",
"reasonCode": "FORMATION_SUBMISSION_FAILED",
"message": "Doola formation submission failed after the configured retry budget. Please contact support."
},
"timestamp": 1756220000000
}
```
```json theme={null}
{
"eventId": "31qF5gH3kMqR8sT2uVwXyZaBcDe",
"eventName": "company_filing_date_updated",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756300000000
}
```
```json theme={null}
{
"eventId": "31wZ4rY6tA3fSkPqWxD8bCgMrQj",
"eventName": "company_ein_issued",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756750000000
}
```
```json theme={null}
{
"eventId": "31pLdpvMh4OfO90moxLgLT8AuQr",
"eventName": "document_aoo_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31pLdj8SKAU9BYNchcohyto2AxC",
"documentType": "ArticlesOfOrganization",
"documentCreatedAt": "2025-08-26 13:49:16.0"
},
"timestamp": 1756216157000
}
```
```json theme={null}
{
"eventId": "31rT8gH9kM4qR2sTuVwXyZaBcDf",
"eventName": "document_einletter_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31rTm4kP9nQ2aLfJkRtY3vXbHmL",
"documentType": "EinLetter",
"documentCreatedAt": "2025-09-02 08:11:42.0"
},
"timestamp": 1756800702000
}
```
```json theme={null}
{
"eventId": "31sV2kP7nQ9aLfJkRtY3vXbHmLg",
"eventName": "document_mail_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31sVq8nT6kM4qR2sTuVwXyZaBcD",
"documentType": "Mail",
"documentCreatedAt": "2025-09-10 17:05:30.0"
},
"timestamp": 1757523930000
}
```
```json theme={null}
{
"eventId": "31tC3nS7qT9eMhNoPvX5zFgLrSm",
"eventName": "document_corporatebylaws_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31tC7rY8wB2fNiOpQwA7aGhMsTn",
"documentType": "CorporateBylaws",
"documentCreatedAt": "2025-08-26 13:52:41.0"
},
"timestamp": 1756216361000
}
```
```json theme={null}
{
"eventId": "31tC5pU9xD4gOjPqRsB8bHiNtUo",
"eventName": "document_operatingagreement_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31tC9sZ0yE6hPkQrTwC9cJjOuVp",
"documentType": "OperatingAgreement",
"documentCreatedAt": "2025-08-26 13:53:07.0"
},
"timestamp": 1756216387000
}
```
```json theme={null}
{
"eventId": "31tW5mR3pS7bMgKlSuZ4wYcInMf",
"eventName": "signature_ss4_completed",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756250000000
}
```
```json theme={null}
{
"eventId": "31tW9nT6qU8cNhLmTvA5xZdJoNg",
"eventName": "document_ss4_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31tWp2rX5sZ7bMgKlSuZ4wYcInM",
"documentType": "SignedSS4",
"documentCreatedAt": "2025-08-27 10:22:09.0"
},
"timestamp": 1756290129000
}
```
```json theme={null}
{
"eventId": "31uX1pV4rW9dOiMnUwB6yAeKpOh",
"eventName": "signature_ss4_reminder_due",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"message": "SS4 signature is still pending. Request a fresh signing URL and re-present it to the user.",
"documentType": "SS4",
"daysSinceRequested": 1
},
"timestamp": 1756160000000
}
```
```json theme={null}
{
"eventId": "31vA2qW5sT8dPjNoVxC7zBfLqPi",
"eventName": "signature_form8821_completed",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ"
},
"timestamp": 1756250050000
}
```
```json theme={null}
{
"eventId": "31vB6rY8tV0eQkOpWyD8aCgMrQj",
"eventName": "document_form8821_uploaded",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"documentId": "31vBq3sZ7tA9cNhLmTvA5xZdJoN",
"documentType": "SignedForm8821",
"documentCreatedAt": "2025-08-27 10:24:41.0"
},
"timestamp": 1756290281000
}
```
```json theme={null}
{
"eventId": "31vC4sZ7uX1fRlPqXzE9bDhNsRk",
"eventName": "signature_form8821_reminder_due",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"message": "Form 8821 signature is still pending. Request a fresh signing URL and re-present it to the user.",
"documentType": "FORM8821",
"daysSinceRequested": 1
},
"timestamp": 1756160050000
}
```
```json theme={null}
{
"eventId": "31wR4kM2nP5qT8vXyZaBcDeFgHi",
"eventName": "company_name_options_required",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"requiredActionId": "31wR7kM2nP5qT8vXyZaBcDeFgHj",
"actionCode": "FORMATION_NAME_OPTIONS_EXHAUSTED",
"actionName": "New company names needed",
"reason": "The state rejected “Acme Holdings LLC” and “Acme Labs LLC”. Submit new company name options to continue the formation."
},
"timestamp": 1756400000000
}
```
```json theme={null}
{
"eventId": "31wS8nQ3rU6vW9xYzAbCdEfGhIk",
"eventName": "signature_ss4_reset",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"requiredActionId": "31wS2mP4qR7sT0uVwXyZaBcDeFg",
"actionCode": "FORMATION_SIGNATURE_SS4_RESET",
"actionName": "SS-4 signature needed again",
"reason": "SS4 must be signed again."
},
"timestamp": 1756450000000
}
```
```json theme={null}
{
"eventId": "31wT9oR4sV7wX0yZaBcDeFgHiJl",
"eventName": "signature_form8821_required",
"eventPayload": {
"doolaCompanyId": "31pLD0Tq2lm2FsgRBoHv4x3BpzZ",
"requiredActionId": "31wT3nQ5rS8tU1vWxYzAbCdEfGh",
"actionCode": "FORMATION_SIGNATURE_FORM8821_REQUIRED",
"actionName": "Form 8821 signature needed",
"reason": "doola could not obtain the company's EIN online. A signed Form 8821 authorizes doola to retrieve it from the IRS directly."
},
"timestamp": 1756500000000
}
```
```json theme={null}
{
"eventId": "31vY3qX5sZ2eRjNoVxC7zBfLqPi",
"eventName": "partner_webhook_disabled",
"eventPayload": null,
"timestamp": 1756600000000
}
```
#### Payload fields
`doolaCompanyId` is on every company- and document-scoped payload. The rest appear only on the events noted below:
| Field | Appears in | Meaning |
| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `documentId` | `document_*` | ID of the uploaded document. Use it with the [document endpoints](/api/domain-objects#document) to download. |
| `documentType` | `document_*`, `signature_ss4_reminder_due`, `signature_form8821_reminder_due` | `ArticlesOfOrganization`, `EinLetter`, `Mail`, `CorporateBylaws`, `OperatingAgreement`, `SignedSS4`, or `SignedForm8821` on document events; `SS4` or `FORM8821` on the matching reminder. |
| `documentCreatedAt` | `document_*` | When doola stored the document. |
| `doolaCustomerId` | `company_formation_failed` | The customer who owns the company. Best-effort; omitted if unavailable. |
| `reasonCode` | `company_formation_failed` | Machine-readable failure reason. Branch on this, never on `message`. |
| `message` | `company_formation_failed`, `signature_ss4_reminder_due`, `signature_form8821_reminder_due` | Human-readable context for the event. |
| `daysSinceRequested` | `signature_ss4_reminder_due`, `signature_form8821_reminder_due` | Days since that document was first requested: `1`, `3`, or `7`. |
| `requiredActionId` | `company_name_options_required`, `signature_ss4_reset`, `signature_form8821_required` | ID of the [required action](/api/required-actions). Key your records on it, and read the action against it. |
| `actionCode` | `company_name_options_required`, `signature_ss4_reset`, `signature_form8821_required` | What is needed: `FORMATION_NAME_OPTIONS_EXHAUSTED`, `FORMATION_SIGNATURE_SS4_RESET` or `FORMATION_SIGNATURE_FORM8821_REQUIRED`. Branch on this, never on `reason`. |
| `actionName` | `company_name_options_required`, `signature_ss4_reset`, `signature_form8821_required` | Short human-readable label for the action. |
| `reason` | `company_name_options_required`, `signature_ss4_reset`, `signature_form8821_required` | Explanation written for your user to read; for name rejections it usually names the rejected names, falling back to a generic sentence when doola could not capture them. |
## Delivery and retries
If your endpoint does not return a `2xx`, doola retries up to 5 times: at 1 minute, 15 minutes, 1 hour, 12 hours, and 24 hours after the previous attempt. Treat every delivery as at least once.
After all retries fail, doola automatically disables your endpoint and sends a final `partner_webhook_disabled` event. Fix your endpoint and re-enable it in the Partner Portal. Re-enabling is a deliberate partner action, so you confirm the endpoint is healthy before traffic resumes.
Every delivery attempt (timestamp, event, HTTP status, attempt number) is visible on the Partner Portal's **Events** page, for the last 90 days.
`eventId` is stable across every retry of the same event, so dedupe on it and keep your handler idempotent.
## Reconciliation
Webhooks are how you learn about a change quickly. They are not the only way to learn about it: every state a webhook announces is also readable from the API, so a missed delivery — your downtime, a disabled endpoint, a dropped retry — is always recoverable without contacting doola.
Read the company:
```bash theme={null}
curl https://api.test.doola.com/v1/partner/companies/{companyId} \
-H "Authorization: dk_test_your_api_key_here"
```
| Missed event | Read instead |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `company_formation_submitted` | `formationSubmissionStatus` is `SUBMITTED` |
| `company_formation_completed` | The **Formation** entry in `services` has `status: "Completed"` |
| `company_formation_failed` | `formationSubmissionStatus` is `FAILED`; read `adminNotes` |
| `company_ein_issued` | `ein` is populated |
| `company_filing_date_updated` | `formationFilingDate`, `formationFilingNumber` |
| `signature_ss4_*`, `signature_form8821_*` | `signatureRequirements` — a `COMPLETED` entry means that document is signed |
| `document_*` | [List the company's documents](/api/domain-objects#document) |
| `company_name_options_required`, `signature_ss4_reset`, `signature_form8821_required` | [The open required-actions list](/api/required-actions#reconciling) |
Reconcile the **Formation** service status, not `formationSubmissionStatus`. The submission status only tracks doola's intake of your request and never reaches a completed value, so a loop waiting for it to say "formed" waits forever. [Two things to track](/api/formation#two-things-to-track) explains the split.
A reconciliation pass that covers everything above:
1. Select the companies you believe are still in flight — anything whose **Formation** or **EinCreation** service is not yet `Completed`.
2. Fetch each one and apply the table above, treating each field as the source of truth over your cached copy.
3. Call [`GET /v1/partner/required-actions`](/api/required-actions#reconciling) once. Anything open there that you have no record of is blocking a formation and needs your input.
Run it on startup, on a schedule, and after any incident on your side or after re-enabling a disabled endpoint. Hourly is plenty: formation milestones are days apart, so a slow sweep loses nothing.
A reconciliation sweep is a backstop, not a substitute for webhooks. Polling every company on a tight interval earns you a [`429 E_RATE_LIMITED`](/api/errors#global-error-reference) long before it makes your data fresher — the events already tell you the moment something changes.
# Authentication
Source: https://docs.doola.com/mcp/authentication
OAuth 2.0 Bearer auth, discovery endpoints, and the MCP session lifecycle.
Every request except the public `/.well-known/*` discovery documents must carry an `Authorization: Bearer ` header. The token is validated by introspection against doola's IAM service, and the resolved identity (`customerId`, email, role) is attached to the request.
**No tool takes a `customerId` argument** — identity rides the bearer. The active `companyId` is resolved on demand from the authenticated customer.
## Discovery
A request without a valid bearer returns `401` with a challenge that points clients at the OAuth metadata, per RFC 9728 and RFC 8414:
```
WWW-Authenticate: Bearer realm="doola-mcp",
resource_metadata="https://mcp.doola.com/.well-known/oauth-protected-resource"
```
MCP clients follow it automatically:
| Endpoint | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------------ |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 Protected Resource Metadata — names the authorization server(s). |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 Authorization Server Metadata — advertises the OAuth endpoints below. |
## OAuth endpoints
Advertised on the configured issuer:
| Endpoint | Use |
| -------------------- | --------------------------- |
| `/oauth2/authorize` | Authorization endpoint |
| `/oauth2/token` | Token endpoint |
| `/oauth2/revoke` | Token revocation |
| `/oauth2/introspect` | Token introspection |
| `/oauth2/register` | Dynamic Client Registration |
* **Grants:** `authorization_code`, `client_credentials`, `refresh_token`.
* **PKCE:** `S256` supported.
Most MCP clients handle this end to end — you add the server URL, click connect, and complete the sign-in. The endpoints above matter only if you implement the OAuth flow yourself.
New users complete signup at the doola web app during the OAuth flow. If the user isn't signed in, send them there before any tool call.
## Session lifecycle
The transport is stateless, and the server serves both protocol eras. Which one applies is decided per request, by the version the request declares.
**On `2026-07-28`** there is no handshake and no session. Send `tools/list` or `tools/call` directly, carrying the required `_meta` fields and the matching request-metadata headers:
```http theme={null}
POST / HTTP/1.1
Authorization: Bearer
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: start_new_company
```
with `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in `params._meta`. Send `io.modelcontextprotocol/clientInfo` too — on this revision it is the only way the server can attribute the call to your client.
**On `2025-11-25` and earlier** the handshake still works exactly as before:
* The server mints a fresh `Mcp-Session-Id` (UUID) on every `initialize` and returns it as a response header.
* Echo that header on subsequent requests. It is no longer required, and a request without it is served rather than rejected, but the server still uses it to tie the calls of one conversation together.
* A new `initialize` mints a new ID — there is no cross-conversation continuity at the transport layer. Continuity is anchored to the OAuth identity instead.
Call `server/discover` to read the revisions this server implements before choosing. It also returns the server's `capabilities` and `instructions` — on `2026-07-28` that is the only place the instructions are served, since there is no `initialize` response to carry them.
## Checkout round trip
**On `2026-07-28`, if your client declares URL-mode elicitation** (`"elicitation": {"url": {}}`), `create_checkout_session` returns an interim result rather than a finished one:
```json theme={null}
{
"resultType": "input_required",
"inputRequests": {
"payment_confirmation": {
"method": "elicitation/create",
"params": { "mode": "url", "url": "https://checkout.stripe.com/...", "message": "..." }
}
},
"requestState": ""
}
```
Show the customer the URL, take their consent, and open it. Then retry the same `create_checkout_session` call with `requestState` echoed back verbatim and `{"action": "accept"}` under `inputResponses.payment_confirmation`. Use a new JSON-RPC id: the retry is an independent request. Do not inspect or construct `requestState`.
An acceptance means the customer agreed to open the link, not that they paid. The server asks Stripe what actually happened, so the retry returns the finished payment result only when Stripe says the session is paid; otherwise it hands back `checkoutSessionId` and steers you to `verify_payment`. Declining or cancelling does the same, which is what a client running without a human to ask should expect.
**Otherwise** the two-action flow applies: `create_checkout_session` returns `checkoutSessionId`, and you pass it back to `verify_payment` yourself. This is the only piece of conversation state a client on that path must thread. A client that declares form-mode elicitation only gets this flow too, since a form request cannot carry a usable checkout link.
# Connect a client
Source: https://docs.doola.com/mcp/connect
Add the doola MCP server to Claude, Claude Code, Grok Build, ChatGPT, Perplexity, Cursor, Replit, Lovable, Vercel v0, or any MCP client.
The doola MCP server lives at one URL:
```
https://mcp.doola.com
```
In many clients **doola** is a searchable app or connector — find it by name. In others, paste the URL above. In Claude Code and Grok Build it installs as a plugin, no URL needed. Either way, complete the OAuth sign-in, then prompt the agent: **"Create a US LLC for me."**
Every client uses the same OAuth flow: find or add **doola**, then sign in to your existing doola account (or create one) and authorize. New users sign up during the flow. See [Authentication](/mcp/authentication) for what happens under the hood.
1. Open [Claude](https://claude.ai) → **Settings** → **Connectors**.
2. Click **Add custom connector**.
3. Enter **Name** `doola` and **MCP Server URL** `https://mcp.doola.com`.
4. Click **Add connector**, then sign in to (or create) your doola account and complete the OAuth flow.
5. Start a new conversation — the doola tools are available.
Custom MCP connectors are available on supported Claude plans and workspaces.
doola ships an installable plugin, so there's no URL to paste.
```bash theme={null}
claude plugin marketplace add doolahq/plugins
claude plugin install doola@doola
```
Then run `/reload-plugins`, or restart Claude Code. The plugin's MCP server is only registered when plugins load, so the doola tools won't appear in the session you installed from until you do one of the two.
The first doola tool call then triggers the OAuth flow: sign in to (or create) your doola account and authorize. Run `/mcp` to see the doola server and its connection state.
The same two commands work as slash commands inside the TUI: `/plugin marketplace add doolahq/plugins`, then `/plugin install doola@doola`.
To give a whole team the plugin without each person adding the marketplace, commit this to the project's `.claude/settings.json`:
```json theme={null}
{
"extraKnownMarketplaces": {
"doola": {
"source": { "source": "github", "repo": "doolahq/plugins" }
}
},
"enabledPlugins": { "doola@doola": true }
}
```
Prefer not to install a plugin? Declare the server in the project's `.mcp.json` instead, same shape as the Cursor tab, pointing at the URL at the top of this page.
doola ships as an installable plugin here too.
```bash theme={null}
grok plugin marketplace add doolahq/plugins
grok plugin install doola@doola --trust
```
`--trust` is required. Grok refuses the install without it, because a plugin can run hooks, MCP servers and skills on your machine.
Confirm it registered with `grok inspect`, which lists the server under **MCP Servers** as `doola (http) plugin: doola`. The first doola tool call triggers the OAuth flow: sign in to (or create) your doola account and authorize.
Grok Build reads Claude Code's plugin configuration. If you already added the doola marketplace in Claude Code, `grok plugin marketplace add` reports it as already configured and you can go straight to the install.
You can also browse and install from inside the TUI with `/marketplace`.
1. Open **ChatGPT** → **Settings** → **Apps** → **Browse Apps**.
2. Search for **doola** and click **Connect**.
3. Sign in to (or create) your doola account and complete the OAuth flow.
doola is now connected and ready to use in ChatGPT.
1. Open your **Perplexity Pro** workspace → **Settings** → **Connectors**.
2. Search for **doola** and click **+ Connect**.
3. Sign in to (or create) your doola account, review the requested permissions, and complete the OAuth flow.
4. You're redirected back to Perplexity — **doola** now appears in your connectors list.
Add to `~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"doola": {
"url": "https://mcp.doola.com"
}
}
}
```
Restart Cursor. doola appears in the MCP panel with a **login / authorize** prompt — click it, sign in to (or create) your doola account, and complete OAuth. The tools then appear under **doola**.
1. Open your Replit workspace → **Settings** → **Integrations** → **MCP Servers for Replit Agent**.
2. Search for **doola** and click **Sign in**.
3. Sign in to (or create) your doola account and complete the OAuth flow.
4. Return to Replit Agent — doola is available in conversations.
1. Open your Lovable project → **Connectors** → **Add Custom Connector**.
2. Select **Add MCP Server** and enter **Name** `doola`, **MCP Server URL** `https://mcp.doola.com`, **Authentication** `OAuth`.
3. Click **Add & Authorize**, then sign in to (or create) your doola account and complete the OAuth flow.
4. Return to your project — doola is available to the Lovable agent.
MCP support is available on supported Lovable Pro and Teams workspaces.
1. Open **v0** → **Settings** → **Integrations** → **Add MCP**.
2. Select **Custom MCP Server** and enter **Name** `doola` and **MCP Server URL** `https://mcp.doola.com`.
3. Choose **OAuth** as the authentication method and click **Add**.
4. Sign in to (or create) your doola account and complete the OAuth flow.
5. Start a new conversation — doola is available in v0.
Every `POST /` requires a bearer token. The example below uses the `initialize` handshake of `2025-06-18`; on `2026-07-28` there is no handshake and no session header, and each request instead carries its own `_meta` and request-metadata headers (see [Authentication](/mcp/authentication)).
**1. Authenticate.** A `POST /` without a token returns `401` with a `WWW-Authenticate` challenge pointing at the OAuth metadata — that's what bootstraps discovery. Complete the OAuth flow (authorization code + PKCE, or client credentials) to get a bearer token; MCP clients do this for you. See [Authentication](/mcp/authentication).
**2. Initialize** (with the bearer — mints a session ID):
```bash theme={null}
curl -i -X POST https://mcp.doola.com/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer " \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "my-client", "version": "0.1" }
}
}'
```
**3. List tools** (echo the session ID + bearer):
```bash theme={null}
curl -X POST https://mcp.doola.com/ \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: " \
-H "Authorization: Bearer " \
-d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }'
```
**4. Call a tool** — start every session with `get_company_onboarding_status`:
```bash theme={null}
curl -X POST https://mcp.doola.com/ \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: " \
-H "Authorization: Bearer " \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": { "name": "get_company_onboarding_status", "arguments": {} }
}'
```
## Point your own agent at it
Agents and developers wiring up a custom client can read a compact reference straight off the server:
```
https://mcp.doola.com/.well-known/agents.md
```
It covers the connection details, what the server will and won't do, and the full tool surface. No token needed. The tool section is generated from the server's live `tools/list` contract, so it always matches what your client receives at runtime.
## What the agent does next
The `initialize` response includes an `instructions` block telling the agent how to drive the flow. From there the agent calls `get_company_onboarding_status`, then loops on `advance_company_onboarding`, following the `nextAction` and `nextActionInput` on each response. See the [end-to-end flow](/mcp/flow).
# Errors
Source: https://docs.doola.com/mcp/errors
JSON-RPC errors, auth challenges, tool-level failures, and how to recover.
## Transport errors
Returned as JSON-RPC errors on the HTTP response:
| Code | HTTP | Meaning |
| -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `-32700` | 400 | The request body is not parseable JSON-RPC. |
| `-32600` | 200 | The body is neither a JSON-RPC request nor a notification. |
| `-32601` | 200 / 404 | Unknown method. `404` when the request declares `2026-07-28`, `200` for earlier revisions. |
| `-32602` | 400 | A `2026-07-28` request is missing a required `_meta` field (`io.modelcontextprotocol/protocolVersion` or `io.modelcontextprotocol/clientCapabilities`). |
| `-32603` | 500 | Internal error. |
| `-32020` | 400 | Header mismatch. A required request-metadata header is missing, or disagrees with the body. |
| `-32022` | 400 | Unsupported protocol version. `data.supported` lists the revisions this server implements. |
Every error body carries `error.data.correlationId`. Quote it when reporting a problem — it is the handle to the server-side log line, and the body deliberately never contains the underlying exception message.
A `403 Forbidden` is returned when the request carries an `Origin` header naming an origin this server does not accept.
## Auth challenges
A `401 Unauthorized` (with a `WWW-Authenticate` challenge) is returned when the bearer token is missing, malformed, or inactive. Re-authenticate via the [OAuth flow](/mcp/authentication).
## Tool-level failures
Validation rejections, missing state, precondition gates, and backend errors are **not** JSON-RPC errors. Per the MCP spec they surface inside the successful response's `result.content`. Inspect `result.isError` and the embedded `error` / `code` / `message`.
For `advance_company_onboarding`, error responses carry the same steering fields as success — follow `nextAction` and `nextActionInput` rather than retrying the same call:
| `code` | Meaning |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `INVALID_INPUT` | Missing or malformed `action` / `params`. |
| `UNKNOWN_ACTION` | The `action` isn't one of the supported values. |
| `PRECONDITIONS_NOT_MET` | Earlier steps are incomplete. `missing` lists them in flow order; `nextActionInput` is the step to do first. |
| `READINESS_CHECK_FAILED` | A transient backend issue while verifying state. Retry in \~10 seconds. |
| `FILING_FAILED` | The submission couldn't be filed. The `message` explains what to retry. |
## "No active session"
The most common runtime failure is a missing or expired identity — typically an expired token. Recover by **re-authenticating** (the bearer is the source of identity), then call `get_company_onboarding_status` to confirm where to resume. Don't redo steps the customer already completed.
# End-to-end flow
Source: https://docs.doola.com/mcp/flow
A full solo-founder formation, call by call.
A typical agent dialogue for a single-member Wyoming LLC, in call order. Each `advance_company_onboarding` response tells you the next action via `nextAction` and `nextActionInput` — this is what that sequence looks like.
The host signs the user in to doola (or sends them to sign up at the web app). See [Authentication](/mcp/authentication).
Find the suggested next step. For a new customer with no company, it points to `set_country_of_residence`.
`params: { countryCode }` — where the founder personally lives (ISO 3166-1 Alpha-3).
`params: { name }` — the primary name, without the `LLC` suffix.
`params: {}` — locks Wyoming and LLC.
`params: {}` — accepts ToS and returns live pricing. Quote `pricing.dueAtCheckout`, `pricing.recurringAnnually`, and `pricing.summary` verbatim.
`params: {}` — share the returned `checkoutUrl`; keep `checkoutSessionId`.
The founder completes the Stripe checkout and returns saying "I paid".
`params: { checkoutSessionId }` — wait for `paymentStatus=Complete` before asking any post-payment question.
Fetch the list, fuzzy-match the founder's description, let them pick a verbatim entry, then set `{ industry, description, sellingPlatform? }`. Keep `description` to 50 characters or fewer.
`params: { name1, name2?, name3? }` — `name1` is the existing primary; add up to two backups.
`params: { useRegisteredAgent: true }` — usually doola's registered agent (included).
`params: { sameAsRegistered: true }` — usually the doola virtual mailing address.
Solo founder: auto-fill the name from the profile, ask only for phone + address.
Auto-fill from the sole member; confirm the email (SS-4 + Form 8821 are sent there).
`params: { members: [{ ..., ownershipPercent: 100 }] }` — 100% to the sole member.
Present a complete markdown summary table of everything collected. Ask the user to reply "submit".
`params: { userConfirmed: true }` — files the formation. **Irreversible.**
For co-founders, `add_members` collects each member's details and `set_ownership` splits the percentages (must total exactly 100%). Everything else is identical.
# doola Formation MCP Server
Source: https://docs.doola.com/mcp/introduction
Let AI agents form a US LLC end to end through doola, over the Model Context Protocol.
The doola Formation MCP server lets AI agents drive **end-to-end US LLC formation** — company setup, payment, post-payment data collection, and final filing — through a single OAuth-secured [Model Context Protocol](https://modelcontextprotocol.io) endpoint.
A host connects, completes the OAuth handshake, calls `get_company_onboarding_status` once to find out where the customer is, then drives the flow by following the `nextAction` and `nextActionInput` fields that every response returns — one step at a time until the formation is filed with the Wyoming Secretary of State.
AI agent builders, partner integrations, and customer-facing assistants that want to offer "form a US LLC" as a capability.
## Endpoint
| | |
| ---------------- | -------------------------------------------------------------------- |
| **URL** | `https://mcp.doola.com` |
| **Method** | `POST /` (JSON-RPC 2.0) |
| **Transport** | Streamable HTTP, stateless server |
| **MCP protocol** | Negotiated per spec (clients commonly request `2025-06-18`) |
| **Server** | `doola-formation-onboarding` / `2.0.0` |
| **Capabilities** | Tools only — no resources, no prompts |
| **Auth** | OAuth 2.0 Bearer (RFC 6750) on every request except `/.well-known/*` |
`GET /` returns `405 Method Not Allowed` (the server never pushes). `DELETE /` returns `204 No Content` (session teardown is a no-op).
## How it works
Add `https://mcp.doola.com` to your MCP client and complete the OAuth flow. See [Connect a client](/mcp/connect). New users sign up at the doola web app during OAuth.
Call `get_company_onboarding_status` once. Its `suggestedNextStep` and `nextActionInput` tell you the exact next call.
Call `advance_company_onboarding` with the `action` for the current step. Each response steers you to the next one — follow `nextAction` and `nextActionInput` verbatim.
After payment and the post-payment details, present a summary, get an explicit "submit", and call `advance_company_onboarding` with `action=submit_for_review`.
## Scope
**Today the server covers Wyoming LLC formation only.** Entity type is always LLC and the package is always Starter. Other states, C-Corp, S-Corp, dissolution, and post-formation compliance are on the roadmap.
## Next steps
Claude, ChatGPT, Perplexity, Cursor, Replit, Lovable, Vercel v0, or raw curl.
OAuth 2.0 discovery, endpoints, and the session lifecycle.
The 6 tools and the 15 formation actions.
A full solo-founder walkthrough, call by call.
# Limitations
Source: https://docs.doola.com/mcp/limitations
What the doola Formation MCP server does and doesn't do today.
* **Tools only.** The server advertises no resources and no prompts.
* **Wyoming LLC only.** `set_company_state` locks the formation state to Wyoming and the entity type to LLC; the package is always Starter. Other states, C-Corp, S-Corp, dissolution, and post-formation compliance are on the roadmap.
* **`submit_for_review` is irreversible.** It files with the Wyoming Secretary of State and emails IRS Form SS-4 + Form 8821 to the responsible party. Always show a summary and require explicit "submit" confirmation.
* **No persistent push channel.** `GET /` returns `405`; the server never initiates messages. Agents drive the flow by issuing requests.
* **Stateless, OAuth-anchored.** Every `initialize` mints a new session ID; continuity is anchored to the OAuth identity (`customerId` from the token), not to transport state. `checkoutSessionId` is the only conversation state the host threads (between `create_checkout_session` and `verify_payment`).
* **Ownership must total 100%.** `set_ownership` validates the sum exactly.
* **`set_industry_description` is strict.** The `industry` value must be a verbatim entry returned by `search_industry`, and `description` is capped at 50 characters (the IRS EIN form's business-activity limit). Both are rejected with a retryable error rather than truncated.
# Tools reference
Source: https://docs.doola.com/mcp/tools
The 6 MCP tools and the 15 formation actions driven by advance_company_onboarding.
The server exposes **6 tools**. The whole formation flow runs through one orchestration tool, `advance_company_onboarding`; the other five handle status, multi-company management, and inspection.
**Conventions.** Identity comes from the OAuth bearer — no tool takes a `customerId` argument. Tool descriptions are agent-facing: they double as the agent's playbook. The flow is server-driven — every `advance_company_onboarding` response carries `nextAction` (follow verbatim) and `nextActionInput` (the literal `{action, params}` to pass back), so the host doesn't plan the multi-step flow itself.
## advance\_company\_onboarding
Drives the entire Wyoming-LLC formation flow. Pick an `action` and pass the matching `params` object. The server enforces step order via precondition gates and steers you with `nextAction` + `nextActionInput` on every response.
**Parameters**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `action` | string | Yes | One of the actions below. |
| `params` | object | Yes | Action-specific parameters. Pass `{}` when the action takes none. Must be a native JSON object — never stringified. |
### Pre-payment actions (in order)
| Action | `params` |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `set_country_of_residence` | `{ countryCode }` — ISO 3166-1 Alpha-3 (e.g. `USA`, `GBR`, `IND`) |
| `set_company_name` | `{ name }` — desired name without the `LLC` suffix. One-time initial capture; re-calling overwrites it and wipes alternates. |
| `set_company_state` | `{}` — Wyoming is enforced. |
| `select_package` | `{}` — Starter is enforced and ToS accepted. Returns live `pricing` (`dueAtCheckout`, `recurringAnnually`, `summary`, `breakdown`) — quote those verbatim, never a hardcoded price. |
| `create_checkout_session` | `{}` — returns `checkoutUrl` (paste verbatim) and `checkoutSessionId` (keep it for verify). |
| `verify_payment` | `{ checkoutSessionId }` — call after the user says they paid. Returns `paymentStatus` (`Complete` when done). |
### Post-payment actions
| Action | `params` |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `search_industry` | `{}` — read-only. Returns the verbatim `industries` list to fuzzy-match against. |
| `set_industry_description` | `{ industry, description, sellingPlatform? }` — `industry` must be a verbatim entry from `search_industry`; `description` is capped at 50 characters. |
| `add_alternate_company_names` | `{ name1, name2?, name3? }` — `name1` is the existing primary (don't re-ask). Each gets the `LLC` suffix. |
| `set_registered_address` | `{ useRegisteredAgent, line1?, city?, state?, postalCode? }` — address fields only when `useRegisteredAgent=false` (must be Wyoming). |
| `set_mailing_address` | `{ sameAsRegistered, line1?, city?, state?, postalCode? }` — address fields only when `sameAsRegistered=false` (must be US). |
| `add_members` | `{ members: [{ legalFirstName, legalLastName, isNaturalPerson, phone, line1, city, state, postalCode, country }] }` — native JSON array. |
| `set_responsible_party` | `{ legalFirstName, legalLastName, email, phone, line1, line2?, city, state, postalCode }` — SS-4 + Form 8821 go to this email; confirm it with the user. |
| `set_ownership` | `{ members: [{ ...full add_members payload, ownershipPercent }] }` — full-record replace; percentages must total exactly 100%. |
| `submit_for_review` | `{ userConfirmed: true }` — only after a complete summary table and an explicit "submit". |
`submit_for_review` is **irreversible**. It files with the Wyoming Secretary of State and emails IRS Form SS-4 + Form 8821 to the responsible party for signing. Always present a complete summary table and require an explicit "submit" before calling. Editing after submission may incur extra costs or delays.
### Response fields
Every `advance_company_onboarding` response shares these steering fields (other tools return their own shapes):
| Field | Meaning |
| ----------------- | ------------------------------------------------------------------------- |
| `nextStep` | Next action name; `null` when the flow is complete. |
| `nextAction` | Imperative for the agent — follow verbatim. |
| `nextActionInput` | Literal `{action, params}` payload to pass back next. |
| `nextQuestion` | Question to ask the user verbatim before the next call. |
| `error` | `true` if the action failed. |
| `code` | Structured error code when `error=true` (see below). |
| `missing` | When `code=PRECONDITIONS_NOT_MET`, the unmet prerequisites in flow order. |
| `message` | Human-readable status or instruction. |
Error `code` values: `INVALID_INPUT`, `UNKNOWN_ACTION`, `PRECONDITIONS_NOT_MET`, `READINESS_CHECK_FAILED`, `FILING_FAILED`. See [Errors](/mcp/errors).
## Helper tools
The canonical "where am I?" entry point — call it **once per session**, right after the OAuth handshake. No arguments. Returns the customer profile, inferred completed steps, and a `suggestedNextStep` + `nextActionInput` that drive the next `advance_company_onboarding` call.
Don't call it before every step — each `advance_company_onboarding` response already carries `nextAction` + `nextActionInput`. Re-call it only when the user returns mid-flow and asks "where are we?", after an unexpected error, or after `switch_active_company`. If the customer has no active company yet, it steers you to `set_country_of_residence`.
Returns every company the authenticated customer owns. Use when the user asks "which companies do I have?", wants to switch context, or before starting a new formation.
**Returns:** `companies[]` (`companyId`, `name`, `state`, `entityType`, `dashboardStatus`, `submitted`, `isActive`) and `activeCompanyId`. The entry with `isActive: true` is the one the formation tools currently operate on.
Begin a brand-new Wyoming LLC formation alongside existing companies — call when the customer explicitly asks to form a second (or further) company. Creates an empty in-progress company, links it to the customer, and pins it as active. The previously active company is untouched and stays in `list_companies`.
**Returns:** `companyId` (new active company) and `suggestedNextStep` (`set_country_of_residence`).
Point the formation tools at a different company the customer already owns. Returns the same shape as `get_company_onboarding_status` for the newly-active company.
**Parameter:** `companyId` (string, required) — must be one of the IDs returned by `list_companies`; unknown IDs are rejected.
Returns the active company's full profile — name, state, entity type, EIN, industry, dashboard status, formation filing date, members, responsible party, addresses, name options, and provisioned services. Use for "tell me about my company", "what's my EIN?", "who are the members?" — distinct from `get_company_onboarding_status`, which returns derived next-step routing rather than raw data.
**Parameters:** none. To inspect a different company, call `switch_active_company` first.