> ## Documentation Index
> Fetch the complete documentation index at: https://docs.doola.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Authenticate with the raw API key in the Authorization header. Never add a Bearer prefix.
> Use the sandbox base URL https://api.test.doola.com and a dk_test_ key in examples unless the user asks for production.
> Send an Idempotency-Key header on POST /v1/partner/customers and POST /v1/partner/companies. No other endpoint takes one.
> Every response is wrapped as {payload, error}. Read fields from payload and branch on error.code, never on the message.
> Drive formation progress from webhooks plus a reconciliation sweep, never a polling loop.
> Embedded SDK (@doola/js): the dk_ key stays on the server and mints customer sessions; the browser gets only a pk_ key and the session. onFormed carries only companyId and can fire more than once. Before charging, read the company with the dk_ key, require formationSubmissionStatus AWAITING_PAYMENT, check the owner's email, and price on the server. Then POST payment-confirmed, which answers 200 with an empty body, and replace the SDK element with a new doola.create() (never append a second one).
> The doola Formation MCP server at https://mcp.doola.com is for founders forming their own company with their own doola account. Partners integrate the Partner API or the Embedded SDK. The /mcp endpoint on this docs site only searches these docs.
> For a full capability summary with the rules above, read https://docs.doola.com/skill.md.

# Take payment

> You own checkout. Charge your customer your own price, then confirm the payment so doola starts the formation.

<Info>
  **You own checkout.** doola never charges your customer, never shows them your price and never handles their card. A formation submitted in the SDK waits at `AWAITING_PAYMENT`, and **nothing is filed until your server confirms payment.**
</Info>

## Who controls what

| You decide | doola does |
| - | - |
| Your price for the formation, and how you package it | Holds the submitted formation until you confirm payment |
| Your currency and your payment provider | Shows the founder the state filing fee for their state, so there are no surprises at your checkout |
| Discounts, bundles, trials and promotions | Files the company, gets the EIN and appoints the registered agent once you confirm |
| Receipts, invoices and any tax on your sale | Keeps the founder on a "payment pending" screen until then |
| Refunds to the founder | Refuses a confirmation for a formation it cancelled, so you know to refund |

How doola bills you for SDK formations is set by your partner agreement.

## The flow

```mermaid theme={null}
sequenceDiagram
  autonumber
  participant F as Founder
  participant SDK as SDK iframe
  participant P as Your page
  participant S as Your server
  participant D as doola API
  F->>SDK: Submit and pay
  SDK->>D: Create the company
  D-->>SDK: AWAITING_PAYMENT
  SDK-->>P: onFormed({ companyId })
  P->>S: Open checkout for companyId
  S->>D: GET company and its customer
  S->>S: Check owner and status, price it
  F->>P: Pays in your checkout
  S->>S: Charge the founder
  S->>D: POST payment-confirmed
  D-->>D: AWAITING_PAYMENT to PENDING, filing starts
  P->>SDK: Mount again: the founder sees their company
```

## Step by step

<Steps>
  <Step title="Open your checkout from onFormed">
    The founder presses **Submit and pay** on the last step of the wizard. doola creates the company at `AWAITING_PAYMENT`, and `onFormed` hands your page its id and nothing else.

    ```ts theme={null}
    onFormed: ({ companyId }) => openCheckout(companyId),
    ```

    Remove the SDK element while your checkout is open, or open the checkout on top of it. If you use the full-screen presentation on phones, removing the element also closes the overlay.

    `onFormed` fires again if the founder comes back to an unpaid formation and asks to pay, so opening a checkout for the same company twice must be safe. See [Charge each company once](#charge-each-company-once).
  </Step>

  <Step title="Look the company up on your server">
    `companyId` came from the browser, where anyone can change it. Before you show a price, your server reads the company with your secret key, using [Get a company](/api/api-reference/companies/get-a-company), and checks two things:

    * **It is waiting on payment.** Charge only while `formationSubmissionStatus` is `AWAITING_PAYMENT`. `PENDING`, `SUBMITTED` and `COMPLETED` mean it is already paid. `CANCELLED`, `FAILED` and any status you don't recognise mean it is not payable: never charge it, and never show it as paid.
    * **It belongs to the signed-in customer.** Read its owner with [Get a customer](/api/api-reference/customers/get-a-customer), using the company's `doolaCustomerId`, and compare the `email` with your signed-in user's.

    ```ts theme={null}
    const DOOLA_API = 'https://api.test.doola.com'; // https://api.doola.com with a dk_live_ key

    interface Company {
      doolaCustomerId: string;
      entityType: 'LLC' | 'CCorp';
      state: string;
      formationSubmissionStatus: string;
    }

    async function doola<T>(path: string): Promise<T> {
      const r = await fetch(`${DOOLA_API}${path}`, {
        headers: { authorization: process.env.DOOLA_API_KEY! }, // your dk_ secret key, no "Bearer"
      });
      if (!r.ok) throw new Error(`doola ${path} answered ${r.status}`);

      return (await r.json()).payload; // every response is wrapped in { payload, error }
    }

    async function payableCompany(companyId: string, user: { email: string }) {
      const company = await doola<Company>(`/v1/partner/companies/${encodeURIComponent(companyId)}`);
      const owner = await doola<{ email: string }>(
        `/v1/partner/customers/${encodeURIComponent(company.doolaCustomerId)}`,
      );

      // Someone else's company: answer 404, exactly as for one that does not exist.
      if (owner.email?.toLowerCase() !== user.email.toLowerCase()) return null;

      const status = company.formationSubmissionStatus;
      if (status === 'AWAITING_PAYMENT') return { company, alreadyPaid: false };
      if (['PENDING', 'SUBMITTED', 'COMPLETED'].includes(status)) return { company, alreadyPaid: true };

      // CANCELLED, FAILED, or a status added after you wrote this: not payable.
      throw new Error(`formation ${companyId} is ${status} and cannot be paid`);
    }
    ```

    When `alreadyPaid` is true, skip the charge and show the founder their company. When it throws, show an error and contact doola.

    Email is the only key doola's customer record shares with your user: it does not return the `externalCustomerId` you send at mint. Minting sessions with `externalCustomerId` keeps doola's email in step with yours.
  </Step>

  <Step title="Price it on your server">
    Build the total from the company, never from anything the browser sends:

    * **Your price** for the formation, by `entityType` (`LLC` or `CCorp`) or however you package it.
    * **The state filing fee**, from [List state filing fees](/api/api-reference/reference-data/list-state-filing-fees) for the company's `entityType`, matched on its `state`. The founder already saw this amount in the wizard as "State filing fee +\$X", so show the same number.

    ```ts theme={null}
    const fees = await doola<{ state: string; priceInCents: number }[]>(
      `/v1/partner/references/state-fees?entityType=${company.entityType}`,
    );
    const stateFee = fees.find((fee) => fee.state === company.state);
    if (!stateFee) throw new Error(`no state filing fee for ${company.entityType} in ${company.state}`);

    const totalCents = YOUR_PRICE_CENTS[company.entityType] + stateFee.priceInCents;
    ```

    If the fee is missing, stop the checkout rather than charging without it. Never fall back to zero.

    The SDK never shows the founder a formation price, so your checkout is where they first see yours.

    <Frame caption="A sample checkout: the partner's own price plus the state fee from doola.">
      <img src="https://mintcdn.com/doola/pax_vNmbRmeuaKRG/images/sdk/partner-checkout.png?fit=max&auto=format&n=pax_vNmbRmeuaKRG&q=85&s=7e9c072a00346ac4db6c8874eed626a6" alt="A checkout listing an LLC formation package at the partner's price, the Wyoming state filing fee and the total" width="2880" height="2000" data-path="images/sdk/partner-checkout.png" />
    </Frame>
  </Step>

  <Step title="Charge the founder">
    Charge with your own payment provider. Keep one order per `companyId`, and give each charge attempt its own idempotency key, for example `${companyId}:${attemptId}`. A double click or a second tab on the same attempt then never charges twice, while a retry after a declined card is a new attempt that can still succeed. Providers such as Stripe replay the first result for a key, failures included, so a key per company would keep returning the decline. If the charge fails, do nothing on the doola side: the formation keeps waiting, and the founder can try again.
  </Step>

  <Step title="Confirm the payment">
    Once the charge succeeds, call [Confirm payment](/api/api-reference/companies/confirm-payment-for-a-draft-formation) from your server. This is what starts the formation.

    <CodeGroup>
      ```ts Node theme={null}
      async function confirmPayment(companyId: string, chargeId: string): Promise<void> {
        const r = await fetch(
          `${DOOLA_API}/v1/partner/companies/${encodeURIComponent(companyId)}/payment-confirmed`,
          {
            method: 'POST',
            headers: { authorization: process.env.DOOLA_API_KEY!, 'content-type': 'application/json' },
            // Your own reference, such as a payment intent or order id. Logged, never verified.
            body: JSON.stringify({ partnerReference: chargeId }),
          },
        );

        // Safe to retry: a second confirmation for the same company changes nothing.
        if (!r.ok) throw new Error(`doola payment-confirmed answered ${r.status}`);
      }
      ```

      ```bash curl theme={null}
      curl https://api.test.doola.com/v1/partner/companies/{companyId}/payment-confirmed \
        -X POST \
        -H "Authorization: dk_test_your_api_key_here" \
        -H "Content-Type: application/json" \
        -d '{ "partnerReference": "pi_3Q1example" }'
      ```
    </CodeGroup>

    A successful confirmation answers `200` with an empty body. The company moves to `PENDING` and doola starts filing it.
  </Step>

  <Step title="Show the founder their company">
    Replace the SDK element with a fresh one, for example `element.replaceWith(doola.create())`, or append a new `doola.create()` if your checkout already took the old one off the page. Never leave the old element beside the new one: each `create()` is its own iframe, and the old one keeps showing the payment screen. The new iframe shows the founder's company, starting with any signature the IRS needs.

    The payment screen inside the iframe does not watch for your confirmation, so it only changes when the SDK mounts again. If the founder stays on it, it tells them to refresh after paying.
  </Step>
</Steps>

## Confirm payment responses

| HTTP | Code | Meaning | What to do |
| - | - | - | - |
| 200 | | Confirmed, or it was already confirmed. Empty body | Nothing. A repeat is a no-op |
| 401 | `E_AUTH_MISSING`, `E_AUTH_INVALID` | Missing or wrong secret key, or a key for the other environment | Fix your configuration |
| 404 | `E_NOT_FOUND` | No such company in your account | Check the id. Never charge for it |
| 409 | `E_COMPANY_CANCELLED` | doola cancelled this formation before payment. The payment was not applied | Refund the founder |

A `200` also comes back for a company that never waited on payment, such as one you created with the Partner API, and it changes nothing there.

## What the founder sees

Right after they submit, the iframe shows "Your company details are in. Payment is the last step." Once your checkout has had a few seconds to open, it adds a small "Payment window did not open? Open it here." link. Once 10 minutes have passed since your checkout last opened, including when the founder comes back on a later day, a **Continue to payment** button takes the link's place. Both fire `onFormed` again.

<Frame caption="An unpaid formation, as the founder sees it when they come back.">
  <img src="https://mintcdn.com/doola/pax_vNmbRmeuaKRG/images/sdk/waiting-for-payment.png?fit=max&auto=format&n=pax_vNmbRmeuaKRG&q=85&s=8b779d876092af6a7ce9916db255b3bc" alt="A card reading Payment pending, with a link to open the payment window" width="1996" height="540" data-path="images/sdk/waiting-for-payment.png" />
</Frame>

## Charge each company once

`onFormed` can fire more than once for the same company: when the founder submits, each time they press **Continue to payment**, and in more than one tab. Key everything on `companyId`:

* **One order per company.** Create or reuse your order by `companyId` when the checkout opens.
* **Check before charging.** Read the company again right before you charge. Skip the charge if it is already paid, and refuse it if it is `CANCELLED`, `FAILED` or a status you don't recognise.
* **Idempotent charges.** Give each charge attempt its own idempotency key under that order, such as `${companyId}:${attemptId}`, so a double click never charges twice and a retry after a decline still goes through.

## When things go wrong

| What happened | State at doola | What to do |
| - | - | - |
| The founder closed the tab before paying | `AWAITING_PAYMENT`. Nothing filed | Nothing. When they open the SDK again, they see the payment screen and can reopen your checkout |
| The charge failed | `AWAITING_PAYMENT` | Let them retry in your checkout. Do not call doola |
| The charge succeeded but your confirmation call failed | `AWAITING_PAYMENT` | Retry the confirmation. It is safe to repeat |
| The founder was charged twice | Confirmed by the first payment | Refund the extra charge, and keep one order per `companyId` with an idempotency key per charge attempt |
| Confirmation answered `409 E_COMPANY_CANCELLED` | `CANCELLED` | Refund the founder |
| The founder wants to cancel before paying | `AWAITING_PAYMENT` | Contact doola to cancel it. Only doola can cancel a formation, and cancelling also lets that customer start a new one |
| The founder wants a refund after you confirmed | `PENDING` or later. Filing has started | Your refund policy applies. Contact doola about the formation itself |

## Keep your records in step

doola sends no webhook while a formation waits on payment: the first event for an SDK company is `company_formation_submitted`, after you confirm. So your own records are the source of truth for unpaid formations.

* **Save `companyId` with your order** as soon as your checkout opens.
* **Sweep charged orders that were never confirmed.** For each, retry the confirmation. Run it on a schedule, for example every hour.
* **Sweep open orders.** Read each company with [Get a company](/api/api-reference/companies/get-a-company). `CANCELLED` means the order is void. A company that is already past `AWAITING_PAYMENT` was paid.

To find a customer's companies without an order, list them with `GET /v1/partner/companies?customerId={doolaCustomerId}`.

## Test it

In sandbox, use your test card or your provider's test mode, then confirm with your `dk_test_` key exactly as in production. To move the company on to formed and EIN issued, use the [sandbox playground](/api/sandbox-playground) **after** you confirm payment. See [Testing](/sdk/testing).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.