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

# Create sessions

> Add one route to your server that exchanges your secret key for a short-lived session for the signed-in customer.

The SDK never sees your secret key. Instead, your page asks your server for a **customer session**, and your server creates one with doola for the user who is signed in to your product. The loader calls your route when the SDK mounts, again before each session expires, and each time the founder presses **Try again** after a failed session.

Your route must:

1. **Read the customer from your own login,** never from the request body.
2. **Answer 401 only when nobody is signed in.** The loader treats your 401 as "your user signed out".
3. **Create the session with doola** using your `dk_` secret key.
4. **Return only `accessToken` and `expiresIn`,** with `Cache-Control: no-store`.

## Next.js and web-standard runtimes

`@doola/js/server` builds the route for you. `createSessionHandler` takes a web-standard `Request` and returns a `Response`, so it is a Next.js route handler as written, and a one-line wrapper in Remix, Hono, Bun, Deno and Cloudflare Workers (where `process.env` needs the `nodejs_compat` flag).

```ts app/doola-session/route.ts theme={null}
import { createSessionHandler } from '@doola/js/server';

export const POST = createSessionHandler({
  // Your dk_ secret key. Its prefix selects the API host. A missing key is refused per
  // request, so `next build` passes without it, and a pk_ key throws here.
  apiKey: process.env.DOOLA_API_KEY,

  // Return null when nobody is signed in. The route answers 401.
  getCustomer: async (request) => {
    const user = await getSignedInUser(request); // your own auth
    return user ? { email: user.email, externalCustomerId: user.id } : null;
  },

  // The browser gets the status, and doola's code on a 409. This tells you why.
  onFailure: (failure) => console.error('doola session failed', failure),
});
```

## Express, Fastify and your own routing

`createCustomerSession` returns the status and body to send:

```ts theme={null}
import { createCustomerSession } from '@doola/js/server';

app.post('/doola-session', async (req, res, next) => {
  try {
    const user = await getSignedInUser(req); // your own auth
    if (!user) {
      res.status(401).end();
      return;
    }

    const { status, body, code, failure } = await createCustomerSession({
      apiKey: process.env.DOOLA_API_KEY,
      customer: { email: user.email, externalCustomerId: user.id },
    });

    if (failure) console.error('doola session failed', failure);

    res.set('Cache-Control', 'no-store');
    if (body) res.status(status).json(body);
    else if (code) res.status(status).json({ code });
    else res.status(status).end();
  } catch (error) {
    // A missing or invalid key rejects. Express 4 does not catch a rejected handler.
    next(error);
  }
});
```

## Any other language

Call [Create a customer session](/api/api-reference/customer-sessions/create-a-customer-session) with your secret key as the whole `Authorization` header, with no `Bearer` prefix:

<CodeGroup>
  ```bash curl theme={null}
  curl https://api.test.doola.com/v1/partner/customer-sessions \
    -X POST \
    -H "Authorization: dk_test_your_api_key_here" \
    -H "Content-Type: application/json" \
    -d '{ "email": "maya@example.com", "externalCustomerId": "user_8412" }'
  ```

  ```python Python theme={null}
  import os
  import requests

  DOOLA_API = "https://api.test.doola.com"  # https://api.doola.com with a dk_live_ key

  def _envelope(r):
      """doola's { payload, error }, or {} for a body that is not one (a proxy's HTML page)."""
      try:
          body = r.json()
      except ValueError:
          return {}
      return body if isinstance(body, dict) else {}

  def doola_session(user):
      """Returns (status, body) for your /doola-session route."""
      if user is None:
          return 401, None

      try:
          r = requests.post(
              f"{DOOLA_API}/v1/partner/customer-sessions",
              headers={"Authorization": os.environ["DOOLA_API_KEY"]},
              json={"email": user.email, "externalCustomerId": str(user.id)},
              timeout=10,
          )
      except requests.RequestException:
          return 502, None

      if r.status_code == 401:
          return 502, None  # your key is wrong, not your user's session
      if r.status_code == 409:
          error = _envelope(r).get("error")
          code = error.get("code") if isinstance(error, dict) else None
          return 409, {"code": code} if isinstance(code, str) else None
      if not r.ok:
          return r.status_code, None

      payload = _envelope(r).get("payload")
      if not isinstance(payload, dict) or not payload.get("accessToken"):
          return 502, None

      return 200, {"accessToken": payload["accessToken"], "expiresIn": payload["expiresIn"]}
  ```
</CodeGroup>

```json Response from doola theme={null}
{
  "payload": {
    "accessToken": "cs_test_eyJhbGciOi...",
    "expiresAt": "2026-10-06T16:10:00Z",
    "expiresIn": 600
  },
  "error": null
}
```

Then keep the rules the helper applies for you:

* **Turn doola's 401 into a 502.** It means your key or account is wrong. Passed through, the loader would read it as "your user signed out" and send a signed-in customer to your login page.
* **Pass other errors through with their status,** and send a 409 with doola's `error.code` as `{ code }`. doola answers 409 for more than one reason, and the loader tells them apart by the code.
* **Answer 502 when doola cannot be reached** or answers with something that is not a session.
* **Unwrap the envelope.** Send the browser only `accessToken` and `expiresIn` from `payload`, never the whole body.
* **Send `Cache-Control: no-store`** on every answer, as for any token.

## What to send

| Field | Required | Used for |
| - | - | - |
| `email` | Yes | The customer's email. Matched after `externalCustomerId` |
| `externalCustomerId` | Recommended | Your own user id, up to 255 characters. Matched first, so a customer who changes their email with you stays the same doola customer, and doola updates their email to match |
| `firstName`, `lastName` | No | Used only when doola creates the customer |
| `countryOfResidence` | No | ISO 3166-1 alpha-3, such as `USA`. Used only when doola creates the customer |
| `phoneNumber` | No | E.164, such as `+12125550100`. Used only when doola creates the customer |

The founder confirms their legal name, phone and country in the first step of the wizard anyway, so sending them is optional.

## The session

* **It lasts 10 minutes** (`expiresIn: 600`). The loader asks your route for a new one at 80% of that, so a mounted SDK renews about every eight minutes, and only while it is mounted.
* **It acts as that one customer.** A `cs_test_` or `cs_live_` token can read and submit only that customer's formation through the SDK. It cannot call the Partner API.
* **It needs no storage.** Your route mints a fresh session on every call. Never cache one, and never send one in a URL.

## Errors from doola

| HTTP | Code | Meaning | `onAuthError` gets |
| - | - | - | - |
| 400 | `E_VALIDATION_FAILED` | A field is invalid. `error.fields` names it, for example `E_EMAIL_INVALID` or `E_PHONE_INVALID` | `mint_failed` |
| 401 | `E_AUTH_MISSING`, `E_AUTH_INVALID` | Your secret key is missing, wrong or for the other environment. Answer 502 | `mint_failed` |
| 403 | `E_ACCESS_DENIED` | The credential is not an API key, such as a Partner Portal sign-in | `mint_failed` |
| 409 | `E_EMAIL_IN_USE` | The email belongs to a doola account outside your partner account, to a Partner Portal user, or to another account your customer would be renamed onto | `email_in_use` |
| 409 | `E_RESOURCE_CONFLICT` | The email's customer is already bound to a different `externalCustomerId`, this `externalCustomerId` belongs to another customer, or an older customer cannot take a new email | `external_id_conflict` |
| 409 | `E_CUSTOMER_REVOKED` | doola has deactivated this customer | `customer_revoked` |
| 429 | `E_RATE_LIMITED` | More than 600 sessions a minute for your account. Wait `Retry-After` seconds | `mint_failed` |

On a renewal, each of these arrives as `renewal_failed`, except `E_RESOURCE_CONFLICT` and `E_CUSTOMER_REVOKED`, which keep their own types. The current session keeps working until it expires.

For the three 409s, the iframe tells the founder what happened ([Customer experience](/sdk/customer-experience#when-something-goes-wrong)), but only when your route forwards the code. A route that answers a 409 with its status alone gets `email_in_use` for all three on the first session, and the founder sees "We could not start your session". For `mint_failed`, the iframe shows "We could not start your session" with **Try again**, which calls your route again, up to three times, and then points the founder to your team. `E_RESOURCE_CONFLICT` usually means your user ids and emails disagree: send the same `externalCustomerId` for a person every time.

## Protect the route

* **Authenticate it** like any other route that acts as your user, and apply your CSRF protection.
* **Accept `POST` only,** so a link or an image tag cannot mint a session.
* **Never let the browser choose the customer.** The email and id come from your session, not the request.
* **Send only an email your app has verified** for the signed-in user, and always send `externalCustomerId`. doola resolves the customer from them, and the code your route forwards on a 409 says whether that email is already a doola customer.


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